7 Commits

Author SHA1 Message Date
a91b9b36d3 fix admin download filename 2026-05-06 00:32:21 +02:00
1a82f21202 Change logging to be json comaptible 2026-05-03 22:42:43 +02:00
d1f6782c96 fix paths oops 2026-04-17 00:07:28 +02:00
ca25cdd924 Add PWA support (I think) 2026-04-16 23:57:42 +02:00
a7541b322b add link button to admin page 2026-04-15 21:13:16 +02:00
root
84af348da7 Add download page link button to admin panel
- Added '🔗 Link' button next to each non-deleted file in admin page
- Links to /f/ViewID (the public download page)
- Opens in new tab
2026-04-15 20:25:48 +02:00
root
73b67ab61d Add reinstate feature for deleted files
- Add MarkNotDeleted method to repository
- Add ReinstateFile method to service
- Add AdminReinstate handler
- Add /reinstate/:id route
- Add Reinstate button in admin menu for deleted files
2026-04-15 16:35:08 +02:00
21 changed files with 595 additions and 39 deletions

View File

@@ -6,6 +6,7 @@ import (
"ResendIt/internal/config" "ResendIt/internal/config"
"ResendIt/internal/db" "ResendIt/internal/db"
"ResendIt/internal/file" "ResendIt/internal/file"
"ResendIt/internal/logger"
"ResendIt/internal/user" "ResendIt/internal/user"
"ResendIt/internal/util" "ResendIt/internal/util"
"ResendIt/internal/web" "ResendIt/internal/web"
@@ -28,29 +29,37 @@ func main() {
fmt.Printf("Error loading .env file\n") fmt.Printf("Error loading .env file\n")
} }
os.Setenv("LOG_FORMAT", "json")
logger.Log.Info().Str("type", "startup").Msg("Starting ReSendIt")
dbCon, err := db.Connect() dbCon, err := db.Connect()
if err != nil { if err != nil {
panic(fmt.Errorf("failed to connect database: %w", err)) logger.Log.Fatal().Err(err).Str("type", "startup").Msg("Failed to connect to database")
} }
err = dbCon.AutoMigrate(&user.User{}, &file.FileRecord{}, &config.ConfigEntry{}) err = dbCon.AutoMigrate(&user.User{}, &file.FileRecord{}, &config.ConfigEntry{})
if err != nil { if err != nil {
fmt.Printf("Error migrating database: %v\n", err) logger.Log.Error().Err(err).Str("type", "startup").Msg("Database migration failed")
return return
} }
// create temp folder // create temp folder
path := "./tmp" path := "./tmp"
if os.IsExist(os.Mkdir(path, os.ModePerm)) { if os.IsExist(os.Mkdir(path, os.ModePerm)) {
fmt.Printf("Temp folder already exists, skipping creation\n") logger.Log.Info().Str("type", "startup").Msg("Temp folder already exists")
} else { } else {
if err := os.MkdirAll(path, os.ModePerm); err != nil { if err := os.MkdirAll(path, os.ModePerm); err != nil {
fmt.Printf("Error creating temp folder: %v\n", err) logger.Log.Error().Err(err).Str("type", "startup").Msg("Failed to create temp folder")
return return
} }
} }
r := gin.Default() // Use gin.New() instead of gin.Default() to have custom middleware
r := gin.New()
// Add structured logging and recovery middleware
r.Use(middleware.StructuredLogger())
r.Use(gin.Recovery())
r.MaxMultipartMemory = 10 << 30 r.MaxMultipartMemory = 10 << 30
r.SetFuncMap(template.FuncMap{ r.SetFuncMap(template.FuncMap{
@@ -60,9 +69,11 @@ func main() {
}) })
r.LoadHTMLGlob("templates/*.html") r.LoadHTMLGlob("templates/*.html")
//r.LoadHTMLGlob("internal/templates/new/*.html")
r.Static("/static", "./static") r.Static("/static", "./static")
// Add request ID middleware
r.Use(middleware.RequestIDMiddleware())
r.GET("/ping", func(c *gin.Context) { r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "hello", "message": "hello",
@@ -85,7 +96,7 @@ func main() {
configService := config.NewService(configRepo) configService := config.NewService(configRepo)
if err := configService.EnsureDefaults(); err != nil { if err := configService.EnsureDefaults(); err != nil {
panic(fmt.Errorf("failed to ensure config defaults: %w", err)) logger.Log.Fatal().Err(err).Str("type", "startup").Msg("Failed to ensure config defaults")
} }
fileRepo := file.NewRepository(dbCon) fileRepo := file.NewRepository(dbCon)
@@ -125,8 +136,15 @@ func main() {
os.Setenv("DOMAIN", domain) os.Setenv("DOMAIN", domain)
} }
logger.Log.Info().
Str("type", "startup").
Str("port", port).
Str("domain", domain).
Msg("Server starting")
err = r.Run(":" + port) err = r.Run(":" + port)
if err != nil { if err != nil {
logger.Log.Error().Err(err).Str("type", "startup").Msg("Server failed to start")
return return
} }
} }
@@ -143,15 +161,15 @@ func createAdminUser(service *user.Service) {
_, err := service.FindByUsername("admin") _, err := service.FindByUsername("admin")
if err == nil { if err == nil {
fmt.Println("Admin user already exists, skipping creation") logger.Log.Info().Str("type", "startup").Msg("Admin user already exists")
return return
} else if errors.Is(err, user.ErrUserNotFound) { } else if errors.Is(err, user.ErrUserNotFound) {
fmt.Println("Admin user not found, creating new admin user") logger.Log.Info().Str("type", "startup").Msg("Creating admin user")
password := generateRandomPassword(16) password := generateRandomPassword(16)
adminUser, err := service.CreateUser("admin", password, "admin") adminUser, err := service.CreateUser("admin", password, "admin")
if err != nil { if err != nil {
fmt.Printf("Error creating admin user: %v\n", err) logger.Log.Error().Err(err).Str("type", "startup").Msg("Failed to create admin user")
return return
} }
@@ -159,13 +177,16 @@ func createAdminUser(service *user.Service) {
_, err = service.UpdateUser(adminUser) _, err = service.UpdateUser(adminUser)
if err != nil { if err != nil {
fmt.Printf("Error creating admin user: %v\n", err) logger.Log.Error().Err(err).Str("type", "startup").Msg("Failed to update admin user")
} else { } else {
fmt.Printf("Admin user created with random password: %s\n", password) logger.Log.Info().
Str("type", "startup").
Str("password", password).
Msg("Admin user created")
} }
return return
} }
fmt.Printf("Error checking for admin user: %v\n", err) logger.Log.Error().Err(err).Str("type", "startup").Msg("Error checking for admin user")
return return
} }

View File

@@ -10,11 +10,12 @@ services:
environment: environment:
JWT_SECRET: supersecretkey JWT_SECRET: supersecretkey
PORT: 8000 PORT: 8000
ADMIN_PASSWORD: ""
DB_TYPE: sqlite DB_TYPE: sqlite
DATABASE_URL: ./data/database.db DATABASE_URL: ./data/database.db
LOG_FORMAT: "json"
DOMAIN: "" DOMAIN: ""
USE_HTTPS: "false" USE_HTTPS: "false"

2
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2 github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.33.0
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
@@ -37,6 +38,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect

12
go.sum
View File

@@ -8,6 +8,7 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -31,6 +32,7 @@ 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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -58,6 +60,10 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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-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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
@@ -69,12 +75,16 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= 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 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -103,7 +113,9 @@ 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/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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= 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/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 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=

View File

@@ -19,6 +19,9 @@ type Claims struct {
func AuthMiddleware() gin.HandlerFunc { func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
log := StructuredLog(c).With().
Str("event", "auth_middleware").
Logger()
var tokenString string var tokenString string
@@ -39,6 +42,7 @@ func AuthMiddleware() gin.HandlerFunc {
} }
if tokenString == "" { if tokenString == "" {
log.Warn().Str("reason", "no_token").Msg("Auth failed - no token provided")
abortUnauthorized(c) abortUnauthorized(c)
return return
} }
@@ -51,13 +55,26 @@ func AuthMiddleware() gin.HandlerFunc {
return jwtSecret, nil return jwtSecret, nil
}) })
if err != nil || !token.Valid { if err != nil {
log.Warn().
Str("reason", "token_parse_error").
Err(err).
Msg("Auth failed - token parse error")
abortUnauthorized(c)
return
}
if !token.Valid {
log.Warn().Str("reason", "invalid_token").Msg("Auth failed - invalid token")
abortUnauthorized(c) abortUnauthorized(c)
return return
} }
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("role", claims.Role) c.Set("role", claims.Role)
c.Set("username", claims.UserID)
log.Debug().Str("user_id", claims.UserID).Str("role", claims.Role).Msg("Auth successful")
c.Next() c.Next()
} }
@@ -76,26 +93,37 @@ func abortUnauthorized(c *gin.Context) {
func RequireRole(roles ...string) gin.HandlerFunc { func RequireRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
log := StructuredLog(c).With().
Str("event", "role_check").
Logger()
roleValue, exists := c.Get("role") roleValue, exists := c.Get("role")
if !exists { if !exists {
log.Warn().Str("reason", "no_role").Msg("Role check failed - no role in context")
abortForbidden(c) abortForbidden(c)
return return
} }
userRole, ok := roleValue.(string) userRole, ok := roleValue.(string)
if !ok { if !ok {
log.Warn().Str("reason", "invalid_role_type").Msg("Role check failed - invalid role type")
abortForbidden(c) abortForbidden(c)
return return
} }
for _, allowed := range roles { for _, allowed := range roles {
if userRole == allowed { if userRole == allowed {
log.Debug().Str("required_roles", strings.Join(roles, ",")).Str("user_role", userRole).Msg("Role check passed")
c.Next() c.Next()
return return
} }
} }
log.Warn().
Str("required_roles", strings.Join(roles, ",")).
Str("user_role", userRole).
Msg("Role check failed - insufficient permissions")
abortForbidden(c) abortForbidden(c)
} }
} }

View File

@@ -1 +1,87 @@
package middleware package middleware
import (
"strconv"
"time"
"ResendIt/internal/logger"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
// StructuredLogger returns a gin middleware that logs HTTP requests in JSON format
func StructuredLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
method := c.Request.Method
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
requestID := c.GetString("request_id")
evt := logger.Log.Info().
Str("type", "http_request").
Str("method", method).
Str("path", path).
Str("query", query).
Int("status", status).
Dur("latency_ms", latency).
Str("client_ip", clientIP).
Str("user_agent", userAgent).
Str("request_id", requestID)
if len(c.Errors) > 0 {
evt = evt.Str("error", c.Errors.ByType(gin.ErrorTypePrivate).String())
}
if userID, exists := c.Get("user_id"); exists {
evt = evt.Str("user_id", userID.(string))
}
if username, exists := c.Get("username"); exists {
evt = evt.Str("username", username.(string))
}
evt.Msg("")
}
}
// RequestIDMiddleware adds a unique request ID to each request
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}
func generateRequestID() string {
// Simple request ID generation - could use uuid package for more entropy
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
// StructuredLog returns a child logger with HTTP context for use in handlers
func StructuredLog(c *gin.Context) zerolog.Logger {
log := logger.Log.With().
Str("type", "app_log").
Str("request_id", c.GetString("request_id"))
if userID, exists := c.Get("user_id"); exists {
log = log.Str("user_id", userID.(string))
}
if username, exists := c.Get("username"); exists {
log = log.Str("username", username.(string))
}
return log.Logger()
}

View File

@@ -114,6 +114,9 @@ func RateLimitByIPDynamic(maxFn func() int, per time.Duration, burstFn func() in
} }
return func(c *gin.Context) { return func(c *gin.Context) {
log := StructuredLog(c).With().
Str("event", "rate_limit_check").
Logger()
// Kinda a shitty fix // Kinda a shitty fix
if c.FullPath() == "/api/files/upload/chunk" || c.FullPath() == "/api/files/upload/init" || c.FullPath() == "/api/files/upload/complete" { if c.FullPath() == "/api/files/upload/chunk" || c.FullPath() == "/api/files/upload/init" || c.FullPath() == "/api/files/upload/complete" {
@@ -136,6 +139,12 @@ func RateLimitByIPDynamic(maxFn func() int, per time.Duration, burstFn func() in
client := getClient(ip, now, max, burst) client := getClient(ip, now, max, burst)
if !client.bucket.allow() { if !client.bucket.allow() {
log.Warn().
Str("ip", ip).
Int("max", max).
Int("burst", burst).
Msg("Rate limit exceeded")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "rate limit exceeded", "error": "rate limit exceeded",
}) })
@@ -144,4 +153,4 @@ func RateLimitByIPDynamic(maxFn func() int, per time.Duration, burstFn func() in
c.Next() c.Next()
} }
} }

View File

@@ -2,6 +2,9 @@ package auth
import ( import (
"os" "os"
"time"
"ResendIt/internal/api/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -37,16 +40,44 @@ func (h *Handler) Login(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
log := middleware.StructuredLog(c)
log.Warn().
Str("event", "login_failed").
Str("reason", "invalid_request").
Str("username", req.Username).
Msg("Login attempt with invalid request")
c.JSON(400, gin.H{"error": "Invalid request body"}) c.JSON(400, gin.H{"error": "Invalid request body"})
return return
} }
log := middleware.StructuredLog(c).With().
Str("event", "login_attempt").
Str("username", req.Username).
Str("ip", c.ClientIP()).
Logger()
start := time.Now()
token, err := h.service.Login(req.Username, req.Password) token, err := h.service.Login(req.Username, req.Password)
latency := time.Since(start)
if err != nil { if err != nil {
log.Warn().
Str("result", "failed").
Dur("latency_ms", latency).
Err(err).
Msg("Login failed")
c.JSON(401, gin.H{"error": "Invalid credentials"}) c.JSON(401, gin.H{"error": "Invalid credentials"})
return return
} }
log.Info().
Str("result", "success").
Dur("latency_ms", latency).
Msg("Login successful")
isSecure := os.Getenv("USE_HTTPS") == "true" isSecure := os.Getenv("USE_HTTPS") == "true"
c.SetCookie( c.SetCookie(
@@ -56,7 +87,7 @@ func (h *Handler) Login(c *gin.Context) {
"/", "/",
os.Getenv("DOMAIN"), os.Getenv("DOMAIN"),
isSecure, isSecure,
true, // httpOnly (IMPORTANT) true,
) )
c.JSON(200, gin.H{"token": token}) c.JSON(200, gin.H{"token": token})

View File

@@ -5,6 +5,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"ResendIt/internal/logger"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
@@ -21,19 +23,43 @@ func Connect() (*gorm.DB, error) {
dsn = "./data/database.db" dsn = "./data/database.db"
} }
logger.Log.Info().
Str("type", "db_connect").
Str("db_type", dbType).
Msg("Connecting to database")
var db *gorm.DB
var err error
switch dbType { switch dbType {
case "sqlite": case "sqlite":
return connectSQLite(dsn) db, err = connectSQLite(dsn)
case "postgres": case "postgres":
return connectPostgres(dsn) db, err = connectPostgres(dsn)
case "mysql": case "mysql":
return connectMySQL(dsn) db, err = connectMySQL(dsn)
default: default:
return nil, fmt.Errorf("unsupported DB_TYPE: %s", dbType) return nil, fmt.Errorf("unsupported DB_TYPE: %s", dbType)
} }
if err != nil {
logger.Log.Error().
Str("type", "db_connect").
Str("db_type", dbType).
Err(err).
Msg("Failed to connect to database")
return nil, err
}
logger.Log.Info().
Str("type", "db_connect").
Str("db_type", dbType).
Msg("Database connected successfully")
return db, nil
} }
func connectSQLite(filePath string) (*gorm.DB, error) { func connectSQLite(filePath string) (*gorm.DB, error) {
@@ -51,6 +77,12 @@ func connectSQLite(filePath string) (*gorm.DB, error) {
return nil, fmt.Errorf("failed to open SQLite database: %w", err) return nil, fmt.Errorf("failed to open SQLite database: %w", err)
} }
sqlDB, err := db.DB()
if err == nil {
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
}
return db, nil return db, nil
} }
@@ -64,6 +96,12 @@ func connectPostgres(dsn string) (*gorm.DB, error) {
return nil, fmt.Errorf("failed to connect to Postgres: %w", err) return nil, fmt.Errorf("failed to connect to Postgres: %w", err)
} }
sqlDB, err := db.DB()
if err == nil {
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
}
return db, nil return db, nil
} }
@@ -77,5 +115,11 @@ func connectMySQL(dsn string) (*gorm.DB, error) {
return nil, fmt.Errorf("failed to connect to MySQL: %w", err) return nil, fmt.Errorf("failed to connect to MySQL: %w", err)
} }
sqlDB, err := db.DB()
if err == nil {
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
}
return db, nil return db, nil
} }

View File

@@ -1,12 +1,13 @@
package file package file
import ( import (
"ResendIt/internal/api/middleware"
"ResendIt/internal/config" "ResendIt/internal/config"
"ResendIt/internal/logger"
"ResendIt/internal/notify" "ResendIt/internal/notify"
"ResendIt/internal/util" "ResendIt/internal/util"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -34,26 +35,38 @@ func NewHandler(s *Service, cfg ConfigService) *Handler {
} }
func (h *Handler) Upload(c *gin.Context) { func (h *Handler) Upload(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "file_upload").
Logger()
err := c.Request.ParseMultipartForm(0) err := c.Request.ParseMultipartForm(0)
if err != nil { if err != nil {
log.Warn().Str("reason", "parse_error").Err(err).Msg("Upload failed")
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
log.Warn().Str("reason", "missing_file").Msg("Upload failed")
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return return
} }
maxSize := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes) maxSize := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes)
if file.Size > maxSize { if file.Size > maxSize {
log.Warn().
Str("reason", "file_too_large").
Int64("file_size", file.Size).
Int64("max_size", maxSize).
Msg("Upload rejected")
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"}) c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
return return
} }
f, err := file.Open() f, err := file.Open()
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to open uploaded file")
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"})
return return
} }
@@ -80,10 +93,18 @@ func (h *Handler) Upload(c *gin.Context) {
duration, duration,
) )
if err != nil { if err != nil {
log.Error().Err(err).Msg("Upload failed")
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
log.Info().
Str("file_id", record.ID).
Str("filename", record.Filename).
Int64("file_size", record.Size).
Bool("once", once).
Msg("File uploaded successfully")
enabled := h.configService.GetIntDefault(config.KeyUseNtfy, config.DefaultUseNtfy) enabled := h.configService.GetIntDefault(config.KeyUseNtfy, config.DefaultUseNtfy)
if enabled == 1 { if enabled == 1 {
ntfyURL := h.configService.GetStringDefault(config.KeyNtfyUrl, "") ntfyURL := h.configService.GetStringDefault(config.KeyNtfyUrl, "")
@@ -94,7 +115,7 @@ func (h *Handler) Upload(c *gin.Context) {
msg := fmt.Sprintf("%s (%s)\nID: %s", record.Filename, util.HumanSize(record.Size), record.ID) msg := fmt.Sprintf("%s (%s)\nID: %s", record.Filename, util.HumanSize(record.Size), record.ID)
clickUrl := fmt.Sprintf("f/%s", record.ViewID) clickUrl := fmt.Sprintf("f/%s", record.ViewID)
if err := notify.Publish(ntfyURL, topic, title, msg, clickUrl); err != nil { if err := notify.Publish(ntfyURL, topic, title, msg, clickUrl); err != nil {
log.Printf("ntfy publish failed: %v", err) logger.Log.Warn().Err(err).Str("type", "ntfy").Msg("ntfy publish failed")
} }
}() }()
} }
@@ -110,13 +131,21 @@ func (h *Handler) Upload(c *gin.Context) {
} }
func (h *Handler) View(c *gin.Context) { func (h *Handler) View(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "file_view").
Logger()
id := c.Param("id") id := c.Param("id")
record, err := h.service.DownloadFile(id) record, err := h.service.DownloadFile(id)
if err != nil { if err != nil {
log.Warn().Str("file_id", id).Err(err).Msg("File view failed - not found")
c.HTML(http.StatusOK, "error.html", nil) c.HTML(http.StatusOK, "error.html", nil)
return return
} }
log.Info().Str("file_id", id).Str("filename", record.Filename).Msg("File viewed")
name := util.SafeFilename(record.Filename) name := util.SafeFilename(record.Filename)
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name)) c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name))
c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Content-Type-Options", "nosniff")
@@ -134,31 +163,46 @@ func isXSSRisk(filename string) bool {
} }
func (h *Handler) Download(c *gin.Context) { func (h *Handler) Download(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "file_download").
Logger()
id := c.Param("id") id := c.Param("id")
record, err := h.service.DownloadFile(id) record, err := h.service.DownloadFile(id)
if err != nil { if err != nil {
log.Warn().Str("file_id", id).Err(err).Msg("File download failed - not found")
c.HTML(http.StatusOK, "error.html", nil) c.HTML(http.StatusOK, "error.html", nil)
return return
} }
log.Info().Str("file_id", id).Str("filename", record.Filename).Int64("size", record.Size).Msg("File downloaded")
name := util.SafeFilename(record.Filename) name := util.SafeFilename(record.Filename)
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name)) c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name))
c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Content-Type-Options", "nosniff")
//c.Header("Content-Security-Policy", "default-src 'none'; img-src 'self'; media-src 'self'; script-src 'none'; style-src 'none';")
//c.Header("Content-Type", "application/octet-stream")
c.File(record.Path) c.File(record.Path)
} }
func (h *Handler) Delete(c *gin.Context) { func (h *Handler) Delete(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "file_delete").
Logger()
id := c.Param("del_id") id := c.Param("del_id")
_, err := h.service.DeleteFileByDeletionID(id) record, err := h.service.DeleteFileByDeletionID(id)
if err != nil { if err != nil {
log.Warn().Str("deletion_id", id).Err(err).Msg("File delete failed")
c.HTML(http.StatusOK, "error.html", nil) c.HTML(http.StatusOK, "error.html", nil)
return return
} }
//c.JSON(http.StatusOK, gin.H{"status": "deleted"}) log.Info().
Str("file_id", record.ID).
Str("filename", record.Filename).
Msg("File deleted")
c.HTML(http.StatusOK, "deleted.html", nil) c.HTML(http.StatusOK, "deleted.html", nil)
} }
@@ -181,22 +225,36 @@ func (h *Handler) AdminGet(c *gin.Context) {
return return
} }
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, util.SafeFilename(record.Filename)))
c.Header("X-Content-Type-Options", "nosniff")
c.File(record.Path) c.File(record.Path)
} }
func (h *Handler) AdminDelete(c *gin.Context) { func (h *Handler) AdminDelete(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "admin_file_delete").
Logger()
id := c.Param("id") id := c.Param("id")
_, err := h.service.DeleteFileByID(id) record, err := h.service.DeleteFileByID(id)
if err != nil { if err != nil {
log.Warn().Str("file_id", id).Err(err).Msg("Admin file delete failed")
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return return
} }
log.Info().Str("file_id", record.ID).Str("filename", record.Filename).Msg("Admin deleted file")
c.Redirect(301, "/admin") c.Redirect(301, "/admin")
} }
func (h *Handler) AdminForceDelete(c *gin.Context) { func (h *Handler) AdminForceDelete(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "admin_file_force_delete").
Logger()
id := c.Param("id") id := c.Param("id")
_, err := h.service.GetFileByID(id) _, err := h.service.GetFileByID(id)
@@ -206,10 +264,37 @@ func (h *Handler) AdminForceDelete(c *gin.Context) {
} }
if _, err := h.service.ForceDelete(id); err != nil { if _, err := h.service.ForceDelete(id); err != nil {
log.Error().Err(err).Msg("Admin force delete failed")
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
log.Info().Str("file_id", id).Msg("Admin force deleted file")
c.Redirect(301, "/admin")
}
func (h *Handler) AdminReinstate(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "admin_file_reinstate").
Logger()
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.ReinstateFile(id); err != nil {
log.Error().Err(err).Msg("Admin reinstate failed")
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
log.Info().Str("file_id", id).Msg("Admin reinstated file")
c.Redirect(301, "/admin") c.Redirect(301, "/admin")
} }
@@ -314,6 +399,10 @@ func (h *Handler) UploadChunk(c *gin.Context) {
} }
func (h *Handler) UploadComplete(c *gin.Context) { func (h *Handler) UploadComplete(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "chunked_upload_complete").
Logger()
var req struct { var req struct {
FileID string `json:"fileId"` FileID string `json:"fileId"`
Filename string `json:"filename"` Filename string `json:"filename"`
@@ -352,7 +441,6 @@ func (h *Handler) UploadComplete(c *gin.Context) {
} }
}() }()
// reuse your existing upload logic 👇
record, err := h.service.UploadFile( record, err := h.service.UploadFile(
req.Filename, req.Filename,
pr, pr,
@@ -360,13 +448,19 @@ func (h *Handler) UploadComplete(c *gin.Context) {
24*time.Hour, 24*time.Hour,
) )
if err != nil { if err != nil {
log.Error().Err(err).Msg("Chunked upload failed")
c.JSON(500, gin.H{"error": err.Error()}) c.JSON(500, gin.H{"error": err.Error()})
return return
} }
// cleanup temp
_ = os.RemoveAll(tmpDir) _ = os.RemoveAll(tmpDir)
log.Info().
Str("file_id", record.ID).
Str("filename", record.Filename).
Int("chunks", req.TotalChunks).
Msg("Chunked upload completed")
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"id": record.ID, "id": record.ID,
"view_key": record.ViewID, "view_key": record.ViewID,

View File

@@ -95,6 +95,12 @@ func (r *Repository) MarkDeleted(f *FileRecord) error {
return r.db.Save(f).Error return r.db.Save(f).Error
} }
// MarkNotDeleted Restore a deleted record by setting Deleted to false
func (r *Repository) MarkNotDeleted(f *FileRecord) error {
f.Deleted = false
return r.db.Save(f).Error
}
// Delete Permanently delete the record from the database // Delete Permanently delete the record from the database
func (r *Repository) Delete(f *FileRecord) error { func (r *Repository) Delete(f *FileRecord) error {
return r.db.Delete(f).Error return r.db.Delete(f).Error

View File

@@ -33,6 +33,7 @@ func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
adminRoutes.GET("/delete/:id", h.AdminDelete) adminRoutes.GET("/delete/:id", h.AdminDelete)
adminRoutes.GET("/delete/fr/:id", h.AdminForceDelete) adminRoutes.GET("/delete/fr/:id", h.AdminForceDelete)
adminRoutes.GET("/reinstate/:id", h.AdminReinstate)
adminRoutes.POST("/import", h.Import) adminRoutes.POST("/import", h.Import)
adminRoutes.GET("/export", h.Export) adminRoutes.GET("/export", h.Export)

View File

@@ -134,6 +134,29 @@ func (s *Service) ForceDelete(id string) (*FileRecord, error) {
return f, nil return f, nil
} }
func (s *Service) ReinstateFile(id string) (*FileRecord, error) {
f, err := s.repo.GetByID(id)
if err != nil {
return nil, err
}
if !f.Deleted {
return nil, ErrFileNotFound // or just return f, nil maybe?
}
// Check if file actually exists on disk
path := s.storageDir + "/" + f.ID
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, ErrFileNotFound
}
if err := s.repo.MarkNotDeleted(f); err != nil {
return nil, err
}
return f, nil
}
func (s *Service) GetPaginatedFiles(limit, offset int) ([]FileRecord, int, error) { func (s *Service) GetPaginatedFiles(limit, offset int) ([]FileRecord, int, error) {
return s.repo.GetPaginated(limit, offset) return s.repo.GetPaginated(limit, offset)
} }

40
internal/logger/logger.go Normal file
View File

@@ -0,0 +1,40 @@
package logger
import (
"os"
"time"
"github.com/rs/zerolog"
)
var Log zerolog.Logger
func init() {
output := os.Stderr
logFormat := os.Getenv("LOG_FORMAT")
if logFormat == "json" {
Log = zerolog.New(output).With().Timestamp().Logger()
} else {
Log = zerolog.New(zerolog.ConsoleWriter{
Out: output,
TimeFormat: time.RFC3339,
}).With().Timestamp().Logger()
}
level := os.Getenv("LOG_LEVEL")
switch level {
case "debug":
Log = Log.Level(zerolog.DebugLevel)
case "warn":
Log = Log.Level(zerolog.WarnLevel)
case "error":
Log = Log.Level(zerolog.ErrorLevel)
default:
Log = Log.Level(zerolog.InfoLevel)
}
}
func With() zerolog.Context {
return Log.With()
}

View File

@@ -2,6 +2,9 @@ package user
import ( import (
"fmt" "fmt"
"time"
"ResendIt/internal/api/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -15,6 +18,10 @@ func NewHandler(service *Service) *Handler {
} }
func (h *Handler) Register(c *gin.Context) { func (h *Handler) Register(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "user_register").
Logger()
var req struct { var req struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
@@ -22,20 +29,43 @@ func (h *Handler) Register(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
log.Warn().
Str("reason", "invalid_request").
Msg("Registration failed")
c.JSON(400, gin.H{"error": "invalid request"}) c.JSON(400, gin.H{"error": "invalid request"})
return return
} }
user, err := h.service.CreateUser(req.Username, req.Password, req.Role) user, err := h.service.CreateUser(req.Username, req.Password, req.Role)
if err != nil { if err != nil {
log.Error().
Err(err).
Str("username", req.Username).
Msg("Registration failed")
c.JSON(500, gin.H{"error": err.Error()}) c.JSON(500, gin.H{"error": err.Error()})
return return
} }
c.JSON(201, gin.H{"id": user.ID, "username": user.Username, "role": user.Role}) log.Info().
Str("user_id", fmt.Sprint(user.ID)).
Str("username", user.Username).
Str("role", user.Role).
Msg("User registered successfully")
c.JSON(201, gin.H{
"id": user.ID,
"username": user.Username,
"role": user.Role,
})
} }
func (h *Handler) ChangePassword(c *gin.Context) { func (h *Handler) ChangePassword(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "password_change").
Logger()
var req struct { var req struct {
OldPassword string `json:"old_password"` OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"` NewPassword string `json:"new_password"`
@@ -43,41 +73,75 @@ func (h *Handler) ChangePassword(c *gin.Context) {
userID, exists := c.Get("user_id") userID, exists := c.Get("user_id")
if !exists { if !exists {
fmt.Println("User ID not found in context")
c.JSON(401, gin.H{"error": "unauthorized"}) c.JSON(401, gin.H{"error": "unauthorized"})
return return
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
log.Warn().
Str("reason", "invalid_request").
Msg("Password change failed")
c.JSON(400, gin.H{"error": "invalid request"}) c.JSON(400, gin.H{"error": "invalid request"})
return return
} }
err := h.service.ChangePassword(userID.(string), req.OldPassword, req.NewPassword) uid := fmt.Sprint(userID)
start := time.Now()
err := h.service.ChangePassword(uid, req.OldPassword, req.NewPassword)
latency := time.Since(start)
if err != nil { if err != nil {
log.Warn().
Str("user_id", uid).
Str("reason", err.Error()).
Dur("latency_ms", latency).
Msg("Password change failed")
c.JSON(500, gin.H{"error": err.Error()}) c.JSON(500, gin.H{"error": err.Error()})
return return
} }
log.Info().
Str("user_id", uid).
Dur("latency_ms", latency).
Msg("Password changed successfully")
c.JSON(200, gin.H{"message": "password changed successfully"}) c.JSON(200, gin.H{"message": "password changed successfully"})
} }
func ForcePasswordChangeMiddleware(userService *Service) gin.HandlerFunc { func ForcePasswordChangeMiddleware(userService *Service) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "force_password_check").
Logger()
userID, exists := c.Get("user_id") userID, exists := c.Get("user_id")
if !exists { if !exists {
c.Next() c.Next()
return return
} }
user, err := userService.FindByID(userID.(string)) uid := fmt.Sprint(userID)
user, err := userService.FindByID(uid)
if err != nil { if err != nil {
log.Error().
Err(err).
Str("user_id", uid).
Msg("Failed to find user for password check")
c.AbortWithStatus(500) c.AbortWithStatus(500)
return return
} }
// Allow access to change password page itself
if user.ForceChangePassword && c.Request.URL.Path != "/change-password" { if user.ForceChangePassword && c.Request.URL.Path != "/change-password" {
log.Warn().
Str("user_id", uid).
Str("path", c.Request.URL.Path).
Msg("Access denied - force password change required")
c.Redirect(302, "/change-password") c.Redirect(302, "/change-password")
c.Abort() c.Abort()
return return

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"ResendIt/internal/api/middleware"
"ResendIt/internal/config" "ResendIt/internal/config"
"net/http" "net/http"
"strconv" "strconv"
@@ -30,6 +31,10 @@ type ConfigPageData struct {
// ConfigPage renders a modular admin config screen. // ConfigPage renders a modular admin config screen.
func (h *Handler) ConfigPage(c *gin.Context) { func (h *Handler) ConfigPage(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "config_page_view").
Logger()
cfg := h.configService cfg := h.configService
maxBytes := cfg.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes) maxBytes := cfg.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes)
@@ -48,10 +53,16 @@ func (h *Handler) ConfigPage(c *gin.Context) {
NtfyTopic: cfg.GetStringDefault(config.KeyNtfyTopic, config.DefaultNtfyTopic), NtfyTopic: cfg.GetStringDefault(config.KeyNtfyTopic, config.DefaultNtfyTopic),
} }
log.Debug().Msg("Config page viewed")
c.HTML(http.StatusOK, "config.html", data) c.HTML(http.StatusOK, "config.html", data)
} }
func (h *Handler) ConfigSave(c *gin.Context) { func (h *Handler) ConfigSave(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "config_save").
Logger()
cfg := h.configService cfg := h.configService
// Parse + validate. // Parse + validate.
@@ -87,22 +98,26 @@ func (h *Handler) ConfigSave(c *gin.Context) {
newMODT, err := strconv.Unquote(`"` + c.PostForm("site_modt") + `"`) newMODT, err := strconv.Unquote(`"` + c.PostForm("site_modt") + `"`)
if err != nil { if err != nil {
log.Warn().Str("reason", "invalid_modtext").Msg("Config save failed")
h.renderConfigError(c, "invalid modtext") h.renderConfigError(c, "invalid modtext")
return return
} }
maxMB, err := parseInt64("upload_max_file_size_mb", 1, 1024*1024) maxMB, err := parseInt64("upload_max_file_size_mb", 1, 1024*1024)
if err != nil { if err != nil {
log.Warn().Str("key", "upload_max_file_size_mb").Msg("Config save failed - invalid value")
h.renderConfigError(c, "invalid max file size") h.renderConfigError(c, "invalid max file size")
return return
} }
maxFiles, err := parseInt("upload_multi_max_files", 1, 500) maxFiles, err := parseInt("upload_multi_max_files", 1, 500)
if err != nil { if err != nil {
log.Warn().Str("key", "upload_multi_max_files").Msg("Config save failed - invalid value")
h.renderConfigError(c, "invalid max files") h.renderConfigError(c, "invalid max files")
return return
} }
maxHours, err := parseInt("upload_max_hours", 1, 24*365) maxHours, err := parseInt("upload_max_hours", 1, 24*365)
if err != nil { if err != nil {
log.Warn().Str("key", "upload_max_hours").Msg("Config save failed - invalid value")
h.renderConfigError(c, "invalid max hours") h.renderConfigError(c, "invalid max hours")
return return
} }
@@ -110,27 +125,32 @@ func (h *Handler) ConfigSave(c *gin.Context) {
// Rate limits: stored, but not applied dynamically yet. // Rate limits: stored, but not applied dynamically yet.
loginPerMin, err := parseInt("ratelimit_login_per_minute", 1, 10000) loginPerMin, err := parseInt("ratelimit_login_per_minute", 1, 10000)
if err != nil { if err != nil {
log.Warn().Str("key", "ratelimit_login_per_minute").Msg("Config save failed - invalid value")
h.renderConfigError(c, "invalid login rate") h.renderConfigError(c, "invalid login rate")
return return
} }
loginBurst, err := parseInt("ratelimit_login_burst", 1, 10000) loginBurst, err := parseInt("ratelimit_login_burst", 1, 10000)
if err != nil { if err != nil {
log.Warn().Str("key", "ratelimit_login_burst").Msg("Config save failed - invalid value")
h.renderConfigError(c, "invalid login burst") h.renderConfigError(c, "invalid login burst")
return return
} }
apiPerMin, err := parseInt("ratelimit_api_per_minute", 1, 100000) apiPerMin, err := parseInt("ratelimit_api_per_minute", 1, 100000)
if err != nil { if err != nil {
log.Warn().Str("key", "ratelimit_api_per_minute").Msg("Config save failed - invalid value")
h.renderConfigError(c, "invalid api rate") h.renderConfigError(c, "invalid api rate")
return return
} }
apiBurst, err := parseInt("ratelimit_api_burst", 1, 100000) apiBurst, err := parseInt("ratelimit_api_burst", 1, 100000)
if err != nil { if err != nil {
log.Warn().Str("key", "ratelimit_api_burst").Msg("Config save failed - invalid value")
h.renderConfigError(c, "invalid api burst") h.renderConfigError(c, "invalid api burst")
return return
} }
useNTFY, err := strconv.ParseBool(c.PostForm("ntfy_use")) useNTFY, err := strconv.ParseBool(c.PostForm("ntfy_use"))
if err != nil { if err != nil {
log.Warn().Str("key", "ntfy_use").Msg("Config save failed - invalid value")
h.renderConfigError(c, "invalid ntfy use value") h.renderConfigError(c, "invalid ntfy use value")
return return
} }
@@ -139,14 +159,17 @@ func (h *Handler) ConfigSave(c *gin.Context) {
// Persist. // Persist.
if err := cfg.SetString(config.KeyUploadMaxFileSizeBytes, strconv.FormatInt(maxMB*1024*1024, 10)); err != nil { if err := cfg.SetString(config.KeyUploadMaxFileSizeBytes, strconv.FormatInt(maxMB*1024*1024, 10)); err != nil {
log.Error().Err(err).Str("key", config.KeyUploadMaxFileSizeBytes).Msg("Config save failed")
h.renderConfigError(c, err.Error()) h.renderConfigError(c, err.Error())
return return
} }
if err := cfg.SetString(config.KeyUploadMultiMaxFiles, strconv.Itoa(maxFiles)); err != nil { if err := cfg.SetString(config.KeyUploadMultiMaxFiles, strconv.Itoa(maxFiles)); err != nil {
log.Error().Err(err).Str("key", config.KeyUploadMultiMaxFiles).Msg("Config save failed")
h.renderConfigError(c, err.Error()) h.renderConfigError(c, err.Error())
return return
} }
if err := cfg.SetString(config.KeyUploadMaxHours, strconv.Itoa(maxHours)); err != nil { if err := cfg.SetString(config.KeyUploadMaxHours, strconv.Itoa(maxHours)); err != nil {
log.Error().Err(err).Str("key", config.KeyUploadMaxHours).Msg("Config save failed")
h.renderConfigError(c, err.Error()) h.renderConfigError(c, err.Error())
return return
} }
@@ -167,6 +190,13 @@ func (h *Handler) ConfigSave(c *gin.Context) {
_ = cfg.SetString(config.KeyNtfyUrl, ntfyUrl) _ = cfg.SetString(config.KeyNtfyUrl, ntfyUrl)
_ = cfg.SetString(config.KeyNtfyTopic, ntfyTopic) _ = cfg.SetString(config.KeyNtfyTopic, ntfyTopic)
log.Info().
Str("modtext", newMODT).
Int64("max_file_size_mb", maxMB).
Int("max_files", maxFiles).
Int("max_hours", maxHours).
Msg("Config saved successfully")
c.Redirect(http.StatusFound, "/config?saved=1") c.Redirect(http.StatusFound, "/config?saved=1")
} }
@@ -183,4 +213,4 @@ func (h *Handler) renderConfigError(c *gin.Context, msg string) {
RateLimitApiBurst: h.configService.GetIntDefault(config.KeyRateLimitApiBurst, config.DefaultRateLimitApiBurst), RateLimitApiBurst: h.configService.GetIntDefault(config.KeyRateLimitApiBurst, config.DefaultRateLimitApiBurst),
} }
c.HTML(http.StatusBadRequest, "config.html", data) c.HTML(http.StatusBadRequest, "config.html", data)
} }

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"ResendIt/internal/api/middleware"
"ResendIt/internal/buildinfo" "ResendIt/internal/buildinfo"
"ResendIt/internal/config" "ResendIt/internal/config"
"ResendIt/internal/file" "ResendIt/internal/file"
@@ -51,16 +52,23 @@ func (h *Handler) LoginPage(c *gin.Context) {
} }
func (h *Handler) FileView(c *gin.Context) { func (h *Handler) FileView(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "file_view_page").
Logger()
id := c.Param("id") id := c.Param("id")
fileRecord, err := h.fileService.GetFileByViewID(id) fileRecord, err := h.fileService.GetFileByViewID(id)
if err != nil { if err != nil {
log.Warn().Str("view_id", id).Err(err).Msg("File view failed - not found")
c.HTML(404, "error.html", gin.H{ c.HTML(404, "error.html", gin.H{
"MODT": h.configService.GetStringDefault(config.KeyModtext, config.DefaultModt), "MODT": h.configService.GetStringDefault(config.KeyModtext, config.DefaultModt),
}) })
return return
} }
log.Info().Str("view_id", id).Str("filename", fileRecord.Filename).Msg("File view page rendered")
downloadKey := fileRecord.ID downloadKey := fileRecord.ID
deleteKey := fileRecord.DeletionID deleteKey := fileRecord.DeletionID
@@ -73,6 +81,10 @@ func (h *Handler) FileView(c *gin.Context) {
} }
func (h *Handler) AdminPage(c *gin.Context) { func (h *Handler) AdminPage(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "admin_page_view").
Logger()
pageStr := c.Query("page") pageStr := c.Query("page")
page, err := strconv.Atoi(pageStr) page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 { if err != nil || page < 1 {
@@ -84,6 +96,7 @@ func (h *Handler) AdminPage(c *gin.Context) {
files, totalCount, err := h.fileService.GetPaginatedFiles(limit, offset) files, totalCount, err := h.fileService.GetPaginatedFiles(limit, offset)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to load files for admin page")
c.HTML(500, "admin.html", gin.H{ c.HTML(500, "admin.html", gin.H{
"error": err.Error(), "error": err.Error(),
}) })
@@ -117,6 +130,8 @@ func (h *Handler) AdminPage(c *gin.Context) {
totalPages := (totalCount + limit - 1) / limit totalPages := (totalCount + limit - 1) / limit
log.Debug().Int("page", page).Int("total_files", totalCount).Msg("Admin page viewed")
c.HTML(200, "admin.html", gin.H{ c.HTML(200, "admin.html", gin.H{
"Files": adminFiles, "Files": adminFiles,
"Page": page, "Page": page,
@@ -126,10 +141,16 @@ func (h *Handler) AdminPage(c *gin.Context) {
} }
func (h *Handler) Logout(c *gin.Context) { func (h *Handler) Logout(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "logout").
Logger()
log.Info().Msg("User logged out")
c.SetCookie("auth_token", "", -1, "/", "", false, true) c.SetCookie("auth_token", "", -1, "/", "", false, true)
c.Redirect(302, "/") c.Redirect(302, "/")
} }
func (h *Handler) ChangePasswordPage(c *gin.Context) { func (h *Handler) ChangePasswordPage(c *gin.Context) {
c.HTML(200, "changePassword.html", nil) c.HTML(200, "changePassword.html", nil)
} }

12
static/manifest.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "ReSendit",
"short_name": "ReSendit",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{ "src": "/logo.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/logo.png", "sizes": "512x512", "type": "image/png" }
]
}

13
static/sw.js Normal file
View File

@@ -0,0 +1,13 @@
self.addEventListener('install', e => {
e.waitUntil(
caches.open('resendit-v1').then(cache => {
return cache.addAll(['/', '/index.html', '/login.html', '/logo.png']);
})
);
});
self.addEventListener('fetch', e => {
e.respondWith(
caches.match(e.request).then(response => response || fetch(e.request))
);
});

View File

@@ -53,6 +53,13 @@
text-decoration: none; text-decoration: none;
text-transform: uppercase; text-transform: uppercase;
box-shadow: 3px 3px 0px #000; box-shadow: 3px 3px 0px #000;
display: inline-flex;
align-items: center;
justify-content: center;
}
button {
line-height: 1;
} }
button:hover, .button:hover { background: #000; color: #fff; box-shadow: none; transform: translate(2px, 2px); } button:hover, .button:hover { background: #000; color: #fff; box-shadow: none; transform: translate(2px, 2px); }
button:active { background: #ff0000; color: #fff; } button:active { background: #ff0000; color: #fff; }
@@ -180,12 +187,17 @@
<td> <td>
<div class="btn-group"> <div class="btn-group">
{{if not .Deleted}} {{if not .Deleted}}
<a href="/f/{{.ViewID}}" target="_blank" class="button" title="Open download page">Link</a>
<form action="/api/files/admin/delete/{{.ID}}" method="GET" onsubmit="return openConfirm(event, 'TERMINATE', 'Kill this file? It will be removed from active storage.')"> <form action="/api/files/admin/delete/{{.ID}}" method="GET" onsubmit="return openConfirm(event, 'TERMINATE', 'Kill this file? It will be removed from active storage.')">
<button type="submit" style="background: #ffcccc;">Terminate</button> <button type="submit" class="button" style="background: #ffcccc;">Terminate</button>
</form>
{{else}}
<form action="/api/files/admin/reinstate/{{.ID}}" method="GET" onsubmit="return openConfirm(event, 'REINSTATE', 'Restore this file to active status?')">
<button type="submit" class="button" style="background: #ccffcc;">Reinstate</button>
</form> </form>
{{end}} {{end}}
<form action="/api/files/admin/delete/fr/{{.ID}}" method="GET" onsubmit="return openConfirm(event, 'FULL_WIPE', 'Wiping file and purging record? This is a permanent database scrub.')"> <form action="/api/files/admin/delete/fr/{{.ID}}" method="GET" onsubmit="return openConfirm(event, 'FULL_WIPE', 'Wiping file and purging record? This is a permanent database scrub.')">
<button type="submit">Full_Wipe</button> <button class="button" type="submit">Full_Wipe</button>
</form> </form>
</div> </div>
</td> </td>

View File

@@ -6,6 +6,12 @@
<title>Send.it</title> <title>Send.it</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"> <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="manifest" href="/static/manifest.json">
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js');
}
</script>
<style> <style>
* { * {
border-radius: 0 !important; border-radius: 0 !important;