This commit is contained in:
2026-03-20 12:33:37 +01:00
commit ce3925423f
44 changed files with 3143 additions and 0 deletions

6
.env.template Normal file
View File

@@ -0,0 +1,6 @@
PORT=8000
JWT_SECRET=
ADMIN_PASSWORD=
DOMAIN=http://localhost:8000
USE_HTTPS=false

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.idea/**
data/**
uploads/**
.env

30
Dockerfile Normal file
View File

@@ -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"]

114
cmd/server/main.go Normal file
View File

@@ -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")
}
}

17
docker-compose.yml Normal file
View File

@@ -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

56
go.mod Normal file
View File

@@ -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
)

124
go.sum Normal file
View File

@@ -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=

View File

@@ -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()
}

View File

@@ -0,0 +1 @@
package middleware

5
internal/auth/errors.go Normal file
View File

@@ -0,0 +1,5 @@
package auth
import "errors"
var ErrInvalidCredentials = errors.New("invalid credentials")

63
internal/auth/handler.go Normal file
View File

@@ -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})
}

31
internal/auth/jwt.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

23
internal/auth/routes.go Normal file
View File

@@ -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)
}

32
internal/auth/service.go Normal file
View File

@@ -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)
}

75
internal/db/db.go Normal file
View File

@@ -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
}

144
internal/file/handlers.go Normal file
View File

@@ -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")
}

18
internal/file/model.go Normal file
View File

@@ -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"`
}

View File

@@ -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
}

28
internal/file/routes.go Normal file
View File

@@ -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)
}

144
internal/file/service.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

35
internal/static/TOS.txt Normal file
View File

@@ -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.

BIN
internal/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
internal/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Console</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<style>
* { border-radius: 0 !important; }
body { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace; background: #fff; color: #000; padding: 20px; }
.box { border: 3px solid #000; background: #fff; }
table { width: 100%; border-collapse: collapse; }
th { background: #000; color: #fff; text-align: left; padding: 10px; font-size: 12px; text-transform: uppercase; border: 1px solid #000; }
td { border: 1px solid #000; padding: 10px; font-size: 13px; font-weight: 500; }
tr:hover { background: #ffff00; }
/* Harsh Status Tags */
.status-tag { font-weight: 900; font-size: 11px; padding: 3px 6px; border: 2px solid #000; display: inline-block; text-transform: uppercase; }
.status-deleted { background: #000; color: #ff0000; }
.status-no { background: #eee; color: #666; }
.status-active { background: #00ff00; color: #000; }
.status-yes { background: #ff00ff; color: #fff; }
/* Chunky Buttons */
.btn-group { display: flex; gap: 5px; }
button, .button {
border: 2px solid #000;
background: #fff;
padding: 4px 10px;
cursor: pointer;
font-size: 11px;
font-weight: 900;
text-decoration: none;
text-transform: uppercase;
box-shadow: 3px 3px 0px #000;
}
button:hover, .button:hover { background: #000; color: #fff; box-shadow: none; transform: translate(2px, 2px); }
button:active { background: #ff0000; color: #fff; }
.nav-link { font-weight: 900; text-decoration: underline; text-transform: uppercase; font-size: 12px; }
.nav-link:hover { background: #000; color: #fff; }
/* --- CUSTOM MODAL STYLES --- */
#modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-box {
border: 8px solid #000;
background: #fff;
padding: 30px;
max-width: 480px;
width: 90%;
box-shadow: 20px 20px 0px #000;
}
#modal-confirm-btn:hover {
background: #ff0000;
color: white;
animation: pulse 0.4s infinite;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.03); }
100% { transform: scale(1); }
}
</style>
</head>
<body>
<div id="modal-overlay">
<div class="modal-box">
<h2 id="modal-title" class="text-4xl font-black uppercase tracking-tighter mb-4">CONFIRM_WIPE</h2>
<div id="modal-message" class="text-sm font-bold mb-8 border-l-8 border-black pl-4 py-2 italic bg-gray-50">
Awaiting system confirmation for permanent data erasure.
</div>
<div class="flex gap-4">
<button id="modal-confirm-btn" class="flex-1 py-4 text-xl border-4 border-black font-black uppercase bg-yellow-400">EXECUTE</button>
<button onclick="closeModal()" class="flex-1 py-4 text-xl border-4 border-black font-black uppercase hover:bg-black hover:text-white">ABORT</button>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto">
<header class="mb-6 border-b-8 border-black pb-4 flex justify-between items-start">
<div>
<h1 class="text-4xl font-black uppercase tracking-tighter leading-none">System_Admin</h1>
</div>
<div class="flex flex-col items-end gap-2">
<a href="/" class="nav-link">← BACK_TO_UPLOADER</a>
<a href="/logout" class="nav-link text-red-600">LOGOUT_SESSION</a>
</div>
</header>
<div class="box overflow-x-auto">
<table>
<thead>
<tr>
<th>File_Identifier</th>
<th>Size</th>
<th>Timeline (In/Out)</th>
<th>Hits</th>
<th>Burn</th>
<th>Status</th>
<th>System_Actions</th>
</tr>
</thead>
<tbody>
{{if not .Files}}
<tr><td colspan="7" class="text-center py-10 font-bold uppercase italic">Zero files in buffer</td></tr>
{{end}}
{{range .Files}}
<tr>
<td class="font-bold">
<a href="/api/files/admin/download/{{.ID}}" target="_blank" class="underline hover:bg-black hover:text-white">{{.Filename}}</a>
</td>
<td class="whitespace-nowrap italic text-gray-600">{{humanSize .Size}}</td>
<td class="text-[11px] leading-tight">
<span class="block"><strong>CRT:</strong> {{.CreatedAt.Format "02/01/06 15:04"}}</span>
<span class="block text-red-600"><strong>EXP:</strong> {{.ExpiresAt.Format "02/01/06 15:04"}}</span>
</td>
<td class="text-center font-black text-lg">{{.DownloadCount}}</td>
<td>
{{if .DeleteAfterDownload}}
<span class="status-tag status-yes">YES</span>
{{else}}
<span class="status-tag status-no">NO</span>
{{end}}
</td>
<td>
{{if .Deleted}}
<span class="status-tag status-deleted">REMOVED</span>
{{else}}
<span class="status-tag status-active">LIVE</span>
{{end}}
</td>
<td>
<div class="btn-group">
{{if not .Deleted}}
<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>
</form>
{{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.')">
<button type="submit">Full_Wipe</button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="mt-6 flex justify-between items-center border-t-8 border-black pt-4">
<div class="flex gap-4">
{{if gt .Page 1}}
<a href="?page={{sub .Page 1}}" class="button">Prev_Page</a>
{{end}}
{{if lt .Page .TotalPages}}
<a href="?page={{add .Page 1}}" class="button">Next_Page</a>
{{end}}
</div>
<footer class="text-right">
<div class="text-[12px] font-black uppercase">
Data_Density: {{len .Files}} records | Page: {{.Page}}/{{.TotalPages}}
</div>
</footer>
</div>
</div>
<script>
let currentForm = null;
function openConfirm(e, title, msg) {
e.preventDefault(); // Stop form from submitting immediately
currentForm = e.target;
document.getElementById('modal-title').innerText = title;
document.getElementById('modal-message').innerText = msg;
document.getElementById('modal-overlay').style.display = 'flex';
return false;
}
function closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
currentForm = null;
}
document.getElementById('modal-confirm-btn').addEventListener('click', () => {
if (currentForm) {
currentForm.submit();
}
});
// Close if clicking outside the box
window.onclick = function(event) {
const overlay = document.getElementById('modal-overlay');
if (event.target == overlay) closeModal();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Deleted sucessfull</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<style>
* { border-radius: 0 !important; transition: none !important; }
body { font-family: sans-serif; background: #fff; color: #000; }
.box {
border: 3px solid #000;
padding: 20px;
background: #fff;
width: 100%;
}
.title {
font-size: 28px;
font-weight: 900;
border-bottom: 3px solid #000;
padding-bottom: 6px;
margin-bottom: 12px;
text-transform: uppercase;
}
.subtitle {
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
margin-bottom: 16px;
}
.button {
border: 2px solid #000;
background: #eee;
padding: 6px 12px;
font-weight: bold;
text-decoration: none;
display: inline-block;
}
.button:hover {
background: #000;
color: #fff;
}
.ascii {
font-family: monospace;
font-size: 11px;
border: 2px dashed #000;
padding: 10px;
margin: 10px 0;
text-align: left;
white-space: pre;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-[520px]">
<div class="box text-center">
<div class="title">
FILE DELETED SUCESSFULL
</div>
<div class="subtitle">
The file has been absolutely obliterated.
</div>
<!-- <div class="ascii">-->
<!-- [ OK ] locating file...-->
<!-- [ OK ] emotionally detaching...-->
<!-- [ OK ] pressing the big red button...-->
<!-- [ OK ] file screaming detected...-->
<!-- [ OK ] scream ignored...-->
<!-- [ OK ] file is now gone forever™-->
<!-- (there is no undo)-->
<!-- </div>-->
<!-- <div class="text-xs font-bold uppercase mb-4">-->
<!-- Congratulations. The electrons have been freed.-->
<!-- </div>-->
<div class="flex flex-col gap-2">
<a href="/" class="button w-full">Pretend Nothing Happened</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nothing to see here</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<style>
* {
border-radius: 0 !important;
transition: none !important;
}
body {
font-family: sans-serif;
background: #fff;
color: #000;
}
.box {
border: 2px solid #000;
padding: 20px;
background: #fff;
width: 100%;
}
.button {
border: 2px solid #000;
background: #eee;
padding: 4px 12px;
font-weight: bold;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.button:hover {
background: #ccc;
}
.button:active {
background: #000;
color: #fff;
}
.title {
font-size: 28px;
font-weight: 900;
border-bottom: 2px solid #000;
margin-bottom: 10px;
padding-bottom: 4px;
}
.subtitle {
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
margin-bottom: 16px;
}
.text {
font-size: 12px;
text-transform: uppercase;
margin-bottom: 20px;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-[493px] flex flex-col items-center">
<div class="box text-center">
<div class="title">
NOTHING TO SEE HERE
</div>
<div class="subtitle">
MOVE ALONG
</div>
<div class="text">
This page is empty,<br>
unavailable, private,<br>
or intentionally left blank.
</div>
<div class="flex flex-col gap-2">
<a href="/" class="button w-full">GO BACK</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 — File Not Found</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<style>
* {
border-radius: 0 !important;
transition: none !important;
}
body {
font-family: sans-serif;
background: #fff;
color: #000;
}
.box {
border: 2px solid #000;
padding: 20px;
background: #fff;
width: 100%;
}
button, .button {
border: 2px solid #000;
background: #eee;
padding: 4px 12px;
font-weight: bold;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
button:hover, .button:hover {
background: #ccc;
}
button:active, .button:active {
background: #000;
color: #fff;
}
.error-code {
font-size: 64px;
font-weight: 900;
border-bottom: 2px solid #000;
margin-bottom: 10px;
}
.error-text {
font-size: 14px;
font-weight: bold;
text-transform: uppercase;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-[493px] flex flex-col items-center">
<div class="box text-center">
<div class="error-code">404</div>
<div class="error-text mb-4">
FILE NOT FOUND 💀
</div>
<div class="text-xs mb-6 uppercase">
The requested file does not exist,<br>
has expired, or was obliterated,<br>or my db is fucked.
We'll never know :D
</div>
<div class="flex flex-col gap-2">
<a href="/" class="button w-full">RETURN TO UPLOADER</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Send.it</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<style>
/* The "No-Design" Design */
* {
border-radius: 0 !important;
transition: none !important;
}
body {
font-family: sans-serif;
background: #fff;
color: #000;
}
.box {
border: 2px solid #000;
padding: 20px;
background: #fff;
width: 100%;
}
.input-text {
border: 1px solid #000;
padding: 4px 8px;
background: #fff;
width: 100%;
}
button {
border: 2px solid #000;
background: #eee;
padding: 4px 12px;
font-weight: bold;
cursor: pointer;
}
button:hover {
background: #ccc;
}
button:active {
background: #000;
color: #fff;
}
button:disabled {
background: #f0f0f0;
color: #999;
border-color: #ccc;
cursor: not-allowed;
}
.btn-cancel {
background: #fff;
color: #cc0000;
border-color: #cc0000;
margin-top: 8px;
width: 100%;
font-size: 10px;
}
.btn-cancel:hover {
background: #fee2e2;
}
.drop-zone {
border: 2px dashed #000;
padding: 80px;
text-align: center;
background: #f9f9f9;
cursor: pointer;
}
.drop-zone.active {
background: #eee;
border-style: solid;
}
.burn-option {
color: #cc0000;
font-weight: bold;
font-size: 12px;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<!--<div class="w-full max-w-[493px] flex flex-col items-end">-->
<div class="w-full max-w-[493px] flex flex-col items-center">
<img src="/static/logo.png" alt="Send.it logo" style="width:50%;" class="mb-2 border-black">
<div class="box">
<header class="mb-6 border-b-2 border-black pb-2 text-center">
<h1 class="text-xl font-bold uppercase">Send it</h1>
</header>
<div id="upload-ui">
<div id="drop-zone" class="drop-zone mb-4">
<input type="file" id="fileInput" class="hidden">
<div id="dz-content">
<span id="dz-text" class="text-sm">Click to select or drop file</span>
</div>
<div id="progress-container" class="hidden mt-3 border border-black h-4">
<div id="progress-bar" class="h-full bg-black" style="width:0%"></div>
</div>
<div class="flex justify-between items-center mt-1">
<div id="progress-text" class="text-[10px] font-bold hidden">0%</div>
<div id="stats-text" class="text-[10px] font-bold hidden uppercase">
<span id="speed-text">0 KB/S</span>
<span class="mx-1 opacity-30">|</span>
<span id="eta-text">--:--</span>
</div>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between border-b border-black pb-2">
<label class="text-xs font-bold uppercase">Expire In:</label>
<select id="duration" class="border border-black text-xs p-1">
<option value="1">1 Hour</option>
<option value="24">24 Hours</option>
<option value="168">7 Days</option>
<option value="730" selected>1 Month</option>
</select>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="once" class="w-4 h-4 border-black">
<label for="once" class="burn-option uppercase">Burn after</label>
</div>
<button id="uploadBtn" class="w-full" disabled>UPLOAD</button>
<button id="cancelBtn" class="btn-cancel hidden">CANCEL UPLOAD</button>
</div>
</div>
<div id="success-ui" class="hidden space-y-4">
<div class="bg-black text-white p-2 text-xs font-bold">
UPLOAD COMPLETE
</div>
<div>
<label class="text-[10px] font-bold block">DOWNLOAD LINK</label>
<div class="flex">
<input id="res-url" readonly class="input-text text-sm">
<button onclick="copy('res-url')" class="border-l-0">COPY</button>
</div>
</div>
<div>
<label class="text-[10px] font-bold block">DELETION LINK (PRIVATE)</label>
<div class="flex">
<input id="res-del" readonly class="input-text text-sm text-red-600">
<button onclick="copy('res-del')" class="border-l-0">COPY</button>
</div>
</div>
<div class="pt-4 flex justify-between">
<button onclick="location.reload()" class="text-xs">NEW UPLOAD</button>
</div>
</div>
</div>
<p class="mt-1 text-[10px] uppercase font-bold text-gray-400">A service by Brammie15</p>
</div>
<script>
const zone = document.getElementById('drop-zone');
const input = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const cancelBtn = document.getElementById('cancelBtn');
const progressText = document.getElementById("progress-text");
const statsText = document.getElementById("stats-text");
const speedText = document.getElementById("speed-text");
const etaText = document.getElementById("eta-text");
const progressBar = document.getElementById("progress-bar");
const progressContainer = document.getElementById("progress-container");
let currentXhr = null;
// Helper: Human Readable Size
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Helper: Human Readable Time
function formatTime(seconds) {
if (!isFinite(seconds) || seconds < 0) return "--:--";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return [
h > 0 ? h : null,
(h > 0 ? m.toString().padStart(2, '0') : m),
s.toString().padStart(2, '0')
].filter(x => x !== null).join(':');
}
zone.onclick = () => input.click();
zone.ondragover = (e) => {
e.preventDefault();
zone.classList.add('active');
};
zone.ondragleave = () => zone.classList.remove('active');
zone.ondrop = (e) => {
e.preventDefault();
zone.classList.remove('active');
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
}
};
input.onchange = () => {
if (input.files.length) {
showFile(input.files[0]);
uploadBtn.disabled = false;
} else {
uploadBtn.disabled = true;
}
};
function showFile(file) {
document.getElementById('dz-text').innerText =
`${file.name} (${formatBytes(file.size)})`;
}
uploadBtn.onclick = () => {
if (input.files.length) handleUpload(input.files[0]);
};
cancelBtn.onclick = (e) => {
e.stopPropagation();
if (currentXhr) {
currentXhr.abort();
alert("Upload cancelled.");
location.reload();
}
};
function handleUpload(file) {
uploadBtn.disabled = true;
uploadBtn.innerText = "UPLOADING...";
cancelBtn.classList.remove('hidden');
progressContainer.classList.remove("hidden");
progressText.classList.remove("hidden");
statsText.classList.remove("hidden");
const fd = new FormData();
fd.append("file", file);
fd.append("once", document.getElementById("once").checked ? "true" : "false");
const hours = parseInt(document.getElementById("duration").value, 10);
fd.append("duration", hours);
const xhr = new XMLHttpRequest();
currentXhr = xhr;
let startTime = Date.now();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = percent + "%";
progressText.innerText = percent + "%";
const elapsedSeconds = (Date.now() - startTime) / 1000;
if (elapsedSeconds > 0) {
const bytesPerSecond = e.loaded / elapsedSeconds;
const remainingBytes = e.total - e.loaded;
const secondsRemaining = remainingBytes / bytesPerSecond;
speedText.innerText = formatBytes(bytesPerSecond) + "/S";
etaText.innerText = formatTime(secondsRemaining);
}
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
if (data.error) throw new Error(data.error);
document.getElementById('upload-ui').classList.add('hidden');
document.getElementById('success-ui').classList.remove('hidden');
const dlUrl = window.location.origin + "/api/files/download/" + data.id;
const delUrl = window.location.origin + "/api/files/delete/" + data.deletion_id;
document.getElementById('res-url').value = dlUrl;
document.getElementById('res-del').value = delUrl;
} catch (err) {
console.error("JSON Parse Error. Server sent:", xhr.responseText);
alert("Server returned an invalid response");
}
} else {
console.error("Server Error:", xhr.status, xhr.responseText);
alert(`Upload failed with status ${xhr.status}. Check console.`);
}
};
xhr.onerror = () => {
if (xhr.statusText !== "abort") {
alert("Upload failed");
location.reload();
}
};
xhr.open("POST", "/api/files/upload");
xhr.send(fd);
}
function copy(id) {
const el = document.getElementById(id);
el.select();
document.execCommand('copy');
}
</script>
<a href="/admin" class="fixed bottom-1 right-1 text-[10px] underline">SUDO</a>
<a href="/static/TOS.txt" class="fixed bottom-1 left-1 text-[10px] underline">TOS</a>
</body>
</html>

View File

@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<style>
* { border-radius: 0 !important; }
body {
font-family: sans-serif;
background: #fff;
color: #000;
padding: 20px;
}
.box {
border: 2px solid #000;
background: #fff;
}
input {
border: 1px solid #000;
padding: 6px;
font-size: 13px;
width: 100%;
background: #fff;
}
input:focus {
outline: none;
background: #f9f9f9;
}
button {
border: 1px solid #000;
background: #eee;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
}
button:hover {
background: #000;
color: #fff;
}
.nav-link {
font-weight: bold;
text-decoration: underline;
font-size: 11px;
}
.label {
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
margin-bottom: 2px;
}
.error {
border: 1px solid #000;
background: #ffcccc;
font-size: 11px;
padding: 4px;
margin-bottom: 10px;
font-weight: bold;
}
</style>
</head>
<body>
<div class="max-w-md mx-auto">
<header class="mb-8 border-b-4 border-black pb-2 flex justify-between items-end">
<h1 class="text-3xl font-black uppercase tracking-tighter">
System Access
</h1>
<a href="/" class="nav-link">
← BACK
</a>
</header>
<div class="box p-4">
{{if .Error}}
<div class="error">
ACCESS DENIED
</div>
{{end}}
<form id="login-form" class="space-y-3">
<div>
<div class="label">Username</div>
<input id="username" required autocomplete="username">
</div>
<div>
<div class="label">Password</div>
<input id="password" type="password" required autocomplete="current-password">
</div>
<div class="pt-2">
<button type="submit">
AUTHENTICATE
</button>
</div>
</form>
</div>
</div>
<script>
const form = document.getElementById("login-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: username,
password: password
})
});
const data = await res.json();
if (!res.ok) {
showError();
return;
}
// Redirect to admin
window.location.href = "/admin";
} catch (err) {
showError();
}
});
function showError() {
let err = document.getElementById("error-box");
if (!err) {
err = document.createElement("div");
err.id = "error-box";
err.className = "error";
err.innerText = "ACCESS DENIED";
form.prepend(err);
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
* { border-radius: 0 !important; }
body { font-family: sans-serif; background: #fff; color: #000; padding: 20px; }
.box { border: 2px solid #000; background: #fff; }
table { width: 100%; border-collapse: collapse; }
th { background: #000; color: #fff; text-align: left; padding: 8px; font-size: 12px; text-transform: uppercase; }
td { border-bottom: 1px solid #000; padding: 8px; font-size: 13px; }
tr:hover { background: #f9f9f9; }
.status-tag { font-weight: bold; font-size: 10px; padding: 2px 4px; border: 1px solid #000; }
.status-deleted { background: #ffcccc; text-decoration: line-through; }
.status-no{ background: #ffcccc; }
.status-active { background: #ccffcc; }
button, .button { border: 1px solid #000; background: #eee; padding: 2px 8px; cursor: pointer; font-size: 11px; font-weight: bold; text-decoration: none; }
button:hover, .button:hover { background: #000; color: #fff; }
.nav-link { font-weight: bold; text-decoration: underline; margin-bottom: 20px; display: inline-block; }
.pagination a { margin: 0 2px; }
</style>
</head>
<body>
<div class="max-w-6xl mx-auto">
<header class="mb-8 border-b-4 border-black pb-2 flex justify-between items-end">
<h1 class="text-3xl font-black uppercase tracking-tighter">System Console</h1>
<div>
<a href="/" class="nav-link text-xs">← BACK TO UPLOADER</a>
<a href="/logout" class="nav-link text-xs">LOGOUT</a>
</div>
</header>
<div class="box overflow-x-auto">
<table>
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Created</th>
<th>Expires</th>
<th>Hits</th>
<th>Burn after</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{if not .Files}}
<tr><td colspan="8">No files found</td></tr>
{{end}}
{{range .Files}}
<tr>
<td class="font-mono">
<a href="/api/files/admin/download/{{.ID}}" target="_blank">{{.Filename}}</a>
</td>
<td>{{humanSize .Size}}</td>
<td>{{.CreatedAt.Format "Jan 02, 2006 15:04"}}</td>
<td>{{.ExpiresAt.Format "Jan 02, 2006 15:04"}}</td>
<td>{{.DownloadCount}}</td>
<td>
{{if .DeleteAfterDownload}}
<span class="status-tag status-active">YES</span>
{{else}}
<span class="status-tag status-no">NO</span>
{{end}}
</td>
<td>
{{if .Deleted}}
<span class="status-tag status-deleted">REMOVED</span>
{{else}}
<span class="status-tag status-active">LIVE</span>
{{end}}
</td>
<td>
{{if not .Deleted}}
<form action="/api/files/admin/delete/{{.ID}}" method="GET" onsubmit="return confirm('Kill this file?')">
<button type="submit">TERMINATE</button>
</form>
{{end}}
<form action="/api/files/admin/delete/fr/{{.ID}}" method="GET" onsubmit="return confirm('Kill this file and the record?')">
<button type="submit">TERMINATE RECORD</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="mt-4 flex justify-between items-center">
<div class="space-x-2">
{{if gt .Page 1}}
<a href="?page={{sub .Page 1}}" class="button">← Prev</a>
{{end}}
{{if lt .Page .TotalPages}}
<a href="?page={{add .Page 1}}" class="button">Next →</a>
{{end}}
</div>
</div>
<footer class="mt-4 text-[10px] text-gray-500 uppercase font-bold">
Showing {{len .Files}} records — Page {{.Page}} of {{.TotalPages}} — Total Pages: {{.TotalPages}}
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Send.it</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* The "No-Design" Design */
* {
border-radius: 0 !important;
transition: none !important;
}
body {
font-family: sans-serif;
background: #fff;
color: #000;
}
.box {
border: 2px solid #000;
padding: 20px;
background: #fff;
width: 100%;
}
.input-text {
border: 1px solid #000;
padding: 4px 8px;
background: #fff;
width: 100%;
}
button {
border: 2px solid #000;
background: #eee;
padding: 4px 12px;
font-weight: bold;
cursor: pointer;
}
button:hover {
background: #ccc;
}
button:active {
background: #000;
color: #fff;
}
button:disabled {
background: #f0f0f0;
color: #999;
border-color: #ccc;
cursor: not-allowed;
}
.btn-cancel {
background: #fff;
color: #cc0000;
border-color: #cc0000;
margin-top: 8px;
width: 100%;
font-size: 10px;
}
.btn-cancel:hover {
background: #fee2e2;
}
.drop-zone {
border: 2px dashed #000;
padding: 80px;
text-align: center;
background: #f9f9f9;
cursor: pointer;
}
.drop-zone.active {
background: #eee;
border-style: solid;
}
.burn-option {
color: #cc0000;
font-weight: bold;
font-size: 12px;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<!--<div class="w-full max-w-[493px] flex flex-col items-end">-->
<div class="w-full max-w-[493px] flex flex-col items-center">
<img src="/static/logo.png" alt="Send.it logo" style="width:50%;" class="mb-2 border-black">
<div class="box">
<header class="mb-6 border-b-2 border-black pb-2 text-center">
<h1 class="text-xl font-bold uppercase">Send it</h1>
</header>
<div id="upload-ui">
<div id="drop-zone" class="drop-zone mb-4">
<input type="file" id="fileInput" class="hidden">
<div id="dz-content">
<span id="dz-text" class="text-sm">Click to select or drop file</span>
</div>
<div id="progress-container" class="hidden mt-3 border border-black h-4">
<div id="progress-bar" class="h-full bg-black" style="width:0%"></div>
</div>
<div class="flex justify-between items-center mt-1">
<div id="progress-text" class="text-[10px] font-bold hidden">0%</div>
<div id="stats-text" class="text-[10px] font-bold hidden uppercase">
<span id="speed-text">0 KB/S</span>
<span class="mx-1 opacity-30">|</span>
<span id="eta-text">--:--</span>
</div>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between border-b border-black pb-2">
<label class="text-xs font-bold uppercase">Expire In:</label>
<select id="duration" class="border border-black text-xs p-1">
<option value="1">1 Hour</option>
<option value="24">24 Hours</option>
<option value="168">7 Days</option>
<option value="730" selected>1 Month</option>
</select>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="once" class="w-4 h-4 border-black">
<label for="once" class="burn-option uppercase">Burn after</label>
</div>
<button id="uploadBtn" class="w-full" disabled>UPLOAD</button>
<button id="cancelBtn" class="btn-cancel hidden">CANCEL UPLOAD</button>
</div>
</div>
<div id="success-ui" class="hidden space-y-4">
<div class="bg-black text-white p-2 text-xs font-bold">
UPLOAD COMPLETE
</div>
<div>
<label class="text-[10px] font-bold block">DOWNLOAD LINK</label>
<div class="flex">
<input id="res-url" readonly class="input-text text-sm">
<button onclick="copy('res-url')" class="border-l-0">COPY</button>
</div>
</div>
<div>
<label class="text-[10px] font-bold block">DELETION LINK (PRIVATE)</label>
<div class="flex">
<input id="res-del" readonly class="input-text text-sm text-red-600">
<button onclick="copy('res-del')" class="border-l-0">COPY</button>
</div>
</div>
<div class="pt-4 flex justify-between">
<button onclick="location.reload()" class="text-xs">NEW UPLOAD</button>
</div>
</div>
</div>
<p class="mt-1 text-[10px] uppercase font-bold text-gray-400">A service by Brammie15</p>
</div>
<script>
const zone = document.getElementById('drop-zone');
const input = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const cancelBtn = document.getElementById('cancelBtn');
const progressText = document.getElementById("progress-text");
const statsText = document.getElementById("stats-text");
const speedText = document.getElementById("speed-text");
const etaText = document.getElementById("eta-text");
const progressBar = document.getElementById("progress-bar");
const progressContainer = document.getElementById("progress-container");
let currentXhr = null;
// Helper: Human Readable Size
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Helper: Human Readable Time
function formatTime(seconds) {
if (!isFinite(seconds) || seconds < 0) return "--:--";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return [
h > 0 ? h : null,
(h > 0 ? m.toString().padStart(2, '0') : m),
s.toString().padStart(2, '0')
].filter(x => x !== null).join(':');
}
zone.onclick = () => input.click();
zone.ondragover = (e) => {
e.preventDefault();
zone.classList.add('active');
};
zone.ondragleave = () => zone.classList.remove('active');
zone.ondrop = (e) => {
e.preventDefault();
zone.classList.remove('active');
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
}
};
input.onchange = () => {
if (input.files.length) {
showFile(input.files[0]);
uploadBtn.disabled = false;
} else {
uploadBtn.disabled = true;
}
};
function showFile(file) {
document.getElementById('dz-text').innerText =
`${file.name} (${formatBytes(file.size)})`;
}
uploadBtn.onclick = () => {
if (input.files.length) handleUpload(input.files[0]);
};
cancelBtn.onclick = (e) => {
e.stopPropagation();
if (currentXhr) {
currentXhr.abort();
alert("Upload cancelled.");
location.reload();
}
};
function handleUpload(file) {
uploadBtn.disabled = true;
uploadBtn.innerText = "UPLOADING...";
cancelBtn.classList.remove('hidden');
progressContainer.classList.remove("hidden");
progressText.classList.remove("hidden");
statsText.classList.remove("hidden");
const fd = new FormData();
fd.append("file", file);
fd.append("once", document.getElementById("once").checked ? "true" : "false");
const hours = parseInt(document.getElementById("duration").value, 10);
fd.append("duration", hours);
const xhr = new XMLHttpRequest();
currentXhr = xhr;
let startTime = Date.now();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = percent + "%";
progressText.innerText = percent + "%";
const elapsedSeconds = (Date.now() - startTime) / 1000;
if (elapsedSeconds > 0) {
const bytesPerSecond = e.loaded / elapsedSeconds;
const remainingBytes = e.total - e.loaded;
const secondsRemaining = remainingBytes / bytesPerSecond;
speedText.innerText = formatBytes(bytesPerSecond) + "/S";
etaText.innerText = formatTime(secondsRemaining);
}
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
if (data.error) throw new Error(data.error);
document.getElementById('upload-ui').classList.add('hidden');
document.getElementById('success-ui').classList.remove('hidden');
const dlUrl = window.location.origin + "/api/files/download/" + data.id;
const delUrl = window.location.origin + "/api/files/delete/" + data.deletion_id;
document.getElementById('res-url').value = dlUrl;
document.getElementById('res-del').value = delUrl;
} catch (err) {
console.error("JSON Parse Error. Server sent:", xhr.responseText);
alert("Server returned an invalid response");
}
} else {
console.error("Server Error:", xhr.status, xhr.responseText);
alert(`Upload failed with status ${xhr.status}. Check console.`);
}
};
xhr.onerror = () => {
if (xhr.statusText !== "abort") {
alert("Upload failed");
location.reload();
}
};
xhr.open("POST", "/api/files/upload");
xhr.send(fd);
}
function copy(id) {
const el = document.getElementById(id);
el.select();
document.execCommand('copy');
}
</script>
<a href="/admin" class="fixed bottom-1 right-1 text-[10px] underline">SUDO</a>
<a href="/static/TOS.txt" class="fixed bottom-1 left-1 text-[10px] underline">TOS</a>
</body>
</html>

View File

@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login // System_Access</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
* { border-radius: 0 !important; transition: none !important; }
body {
font-family: ui-monospace, 'Cascadia Code', monospace;
background: #fff;
color: #000;
padding: 40px 20px;
}
.box {
border: 3px solid #000;
background: #fff;
box-shadow: 6px 6px 0px #000;
}
input {
border: 2px solid #000;
padding: 8px;
font-size: 14px;
width: 100%;
background: #fff;
font-weight: bold;
}
input:focus {
outline: none;
background: #ffff00; /* Yellow highlight on focus */
}
button {
border: 2px solid #000;
background: #fff;
padding: 8px 16px;
cursor: pointer;
font-size: 13px;
font-weight: 900;
text-transform: uppercase;
box-shadow: 4px 4px 0px #000;
}
button:hover {
background: #00ff00; /* Neon green hover */
transform: translate(-1px, -1px);
box-shadow: 5px 5px 0px #000;
}
button:active {
background: #000;
color: #fff;
transform: translate(2px, 2px);
box-shadow: none;
}
.nav-link {
font-weight: 900;
text-decoration: underline;
font-size: 11px;
text-transform: uppercase;
}
.nav-link:hover {
background: #000;
color: #fff;
}
.label {
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
margin-bottom: 4px;
letter-spacing: -0.5px;
}
.error {
border: 3px solid #000;
background: #ff0000;
color: #fff;
font-size: 12px;
padding: 8px;
margin-bottom: 15px;
font-weight: 900;
text-align: center;
text-transform: uppercase;
}
</style>
</head>
<body class="min-h-screen flex flex-col items-center justify-center">
<div class="w-full max-w-[400px]">
<header class="mb-6 border-b-8 border-black pb-2 flex justify-between items-end">
<h1 class="text-3xl font-black uppercase tracking-tighter italic">
Access
</h1>
<a href="/" class="nav-link mb-1">
← RETREAT
</a>
</header>
<div class="box p-6">
<div id="error-container">
{{if .Error}}
<div class="error">
CRITICAL_AUTH_FAILURE
</div>
{{end}}
</div>
<form id="login-form" class="space-y-5">
<div>
<div class="label">User_Identity</div>
<input id="username" required autocomplete="username" placeholder="ID_01">
</div>
<div>
<div class="label">Secure_Passphrase</div>
<input id="password" type="password" required autocomplete="current-password" placeholder="********">
</div>
<div class="pt-2">
<button type="submit" class="w-full">
INITIALIZE_AUTHENTICATION
</button>
</div>
</form>
</div>
<p class="mt-6 text-[10px] uppercase font-black text-gray-400 text-center tracking-widest">
Session_Log: 0.0.0.0 // Node: Auth_Main
</p>
</div>
<script>
const form = document.getElementById("login-form");
const errorContainer = document.getElementById("error-container");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
// Visual feedback
const btn = form.querySelector('button');
btn.innerText = "VERIFYING...";
btn.disabled = true;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: username,
password: password
})
});
if (!res.ok) {
showError();
btn.innerText = "AUTHENTICATE";
btn.disabled = false;
return;
}
window.location.href = "/admin";
} catch (err) {
showError();
btn.innerText = "AUTHENTICATE";
btn.disabled = false;
}
});
function showError() {
errorContainer.innerHTML = `<div class="error">ACCESS_DENIED_BY_SYSTEM</div>`;
// Shake the box for UX effect
const box = document.querySelector('.box');
box.style.transform = "translateX(5px)";
setTimeout(() => box.style.transform = "translateX(-5px)", 50);
setTimeout(() => box.style.transform = "translateX(0)", 100);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Upload</title>
</head>
<body>
<h1>Upload File</h1>
<form action="/api/files/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<button type="submit">Upload</button>
</form>
</body>
</html>

5
internal/user/errors.go Normal file
View File

@@ -0,0 +1,5 @@
package user
import "errors"
var ErrUserNotFound = errors.New("user not found")

32
internal/user/handler.go Normal file
View File

@@ -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})
}

10
internal/user/model.go Normal file
View File

@@ -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"`
}

View File

@@ -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
}

11
internal/user/routes.go Normal file
View File

@@ -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)
}

55
internal/user/service.go Normal file
View File

@@ -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)
}

19
internal/util/util.go Normal file
View File

@@ -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],
)
}

66
internal/web/handler.go Normal file
View File

@@ -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, "/")
}

20
internal/web/routes.go Normal file
View File

@@ -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)
}