Compare commits

45 Commits

Author SHA1 Message Date
9aeb7faa15 fix tmp folder creation 2026-04-14 19:52:44 +02:00
1566ccf348 Update dockerfile 2026-04-14 19:38:18 +02:00
6065b4d95f Add chunked uploads (Resumable curently broken) 2026-04-14 19:14:53 +02:00
8ae5dfc483 add clickable notification 2026-04-10 10:14:28 +02:00
0ce248b2f9 Add QR code, Add MODT customisation 2026-04-09 01:00:02 +02:00
e2f1bbcd64 update login page style 2026-03-26 17:24:51 +01:00
afcb4d72f5 fix styling on some pages 2026-03-26 16:14:13 +01:00
b0d1f17540 add ntfy integration 2026-03-26 14:01:08 +01:00
fc85e859e0 Add ntfy config settings 2026-03-26 13:54:29 +01:00
3116d53b65 Update Jenkinsfile 2026-03-25 22:16:03 +01:00
16c636dc94 Update Jenkinsfile 2026-03-25 22:14:03 +01:00
bf21ccdccd Update Jenkinsfile 2026-03-25 22:09:06 +01:00
5be74d9402 Update Jenkinsfile 2026-03-25 22:00:40 +01:00
a9a59a4f90 Forgot to update the upload :p 2026-03-25 21:44:36 +01:00
a0973373af Update jenkins again 2026-03-25 21:42:21 +01:00
23a2951f8c Merge branch 'master' of https://git.brammie15.dev/brammie15/ReSendit 2026-03-25 21:03:58 +01:00
ead2b18991 Update JenkinsFile
Update Jenkinsfile

Update jenkins file

Update Jenkins

fix paths

Add semgrep

fix fail

god i hate docker

attempt to fix paths

Revert "attempt to fix paths"

This reverts commit 6d60a8663e.

try to fix

asdasd

another update ah

fix config

true fix
2026-03-25 21:03:41 +01:00
781e4f3100 true fix 2026-03-25 20:51:11 +01:00
c2d799eb18 fix config 2026-03-25 20:48:44 +01:00
b4bbaf25c9 another update ah 2026-03-25 20:46:10 +01:00
524f2deb50 asdasd 2026-03-25 20:25:33 +01:00
b3dcdf09be try to fix 2026-03-25 20:24:13 +01:00
c478a4306a Revert "attempt to fix paths"
This reverts commit 6d60a8663e.
2026-03-25 20:14:11 +01:00
6d60a8663e attempt to fix paths 2026-03-25 20:11:38 +01:00
b9c40596b3 god i hate docker 2026-03-25 20:08:57 +01:00
b31d39d971 fix fail 2026-03-25 20:03:26 +01:00
253308dcc5 Add semgrep 2026-03-25 19:27:40 +01:00
90d1c1b562 fix paths 2026-03-25 19:02:19 +01:00
1fe45eaed1 Update Jenkins 2026-03-25 19:00:28 +01:00
a6979805c1 Update jenkins file 2026-03-25 18:57:44 +01:00
644cf426d6 Update Jenkinsfile 2026-03-25 18:54:06 +01:00
f11c758008 Update JenkinsFile 2026-03-25 11:34:17 +01:00
bfeeaa1190 fix: docs 2026-03-25 00:11:57 +01:00
ad656fd636 fix: Dockerfile updated 2026-03-25 00:07:00 +01:00
root
fa8c6d02fd feat: show build commit on admin page 2026-03-24 23:51:42 +01:00
root
fc67533db9 ci: add Jenkins pipeline to build and push docker image 2026-03-24 23:46:35 +01:00
ccb4ff7ecb Update style for config page 2026-03-24 20:09:40 +01:00
root
ba06fb0c7c Add admin config page and runtime-tunable upload/rate-limit settings 2026-03-24 13:56:56 +01:00
root
d9de02f08d Add per-IP rate limiting (login + general API) 2026-03-24 11:40:36 +01:00
e2d8bd344d Merge branch 'zip-support' 2026-03-23 17:46:49 +01:00
82eb9de5f1 fix file naming sanitisation 2026-03-23 17:46:27 +01:00
5bcca61d59 Move wordlist and increase file limit 2026-03-23 17:17:57 +01:00
root
db5c2558f8 Zip bundles: generate cutesy zip display names 2026-03-23 17:10:15 +01:00
root
09d919ca27 Add multi-file upload that zips files server-side 2026-03-23 17:02:26 +01:00
root
50fa003842 Admin page: show actual file presence dot per row 2026-03-23 16:47:20 +01:00
32 changed files with 2477 additions and 500 deletions

View File

@@ -1,3 +1,4 @@
uploads/** uploads/**
data/** data/**
logs/** logs/**
tmp/**

1
.gitignore vendored
View File

@@ -2,4 +2,5 @@
data/** data/**
uploads/** uploads/**
tmp/**
.env .env

View File

@@ -1,5 +1,6 @@
FROM golang:1.26-alpine AS builder FROM golang:1.26-alpine AS builder
ARG GIT_COMMIT=dev
WORKDIR /app WORKDIR /app
@@ -13,7 +14,7 @@ COPY . .
ENV CGO_ENABLED=1 ENV CGO_ENABLED=1
ENV GIN_MODE=release ENV GIN_MODE=release
RUN go build -o app ./cmd/server RUN go build -ldflags "-X ResendIt/internal/buildinfo.Commit=${GIT_COMMIT}" -o app ./cmd/server
FROM alpine:latest FROM alpine:latest
@@ -24,11 +25,12 @@ RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/app . COPY --from=builder /app/app .
COPY --from=builder /app/templates ./templates COPY --from=builder /app/templates ./templates
COPY --from=builder /app/static ./static COPY --from=builder /app/static ./static
COPY --from=builder /app/.env ./
RUN mkdir -p /app/uploads RUN mkdir -p /app/uploads
RUN mkdir -p /app/tmp
RUN adduser -D appuser RUN adduser -D appuser
RUN chown -R appuser:appuser /app
USER appuser USER appuser
ENV GIN_MODE=release ENV GIN_MODE=release

115
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,115 @@
pipeline {
agent any
options {
timestamps()
disableConcurrentBuilds()
}
environment {
REGISTRY = "git.brammie15.dev"
IMAGE_NAME = "brammie15/resendit"
REGISTRY_CREDS = "registry-creds"
IMAGE = "${REGISTRY}/${IMAGE_NAME}"
DD_URL = "https://DD.brammie15.dev"
DD_API_KEY = credentials('dd-api-key')
NVD_API_KEY = credentials("nvd-api-key")
}
stages {
stage('Debug') {
steps {
sh 'echo "WORKSPACE: $WORKSPACE" && echo "PWD: $(pwd)" && ls -la'
}
}
stage('Checkout') {
steps {
checkout scm
}
}
// stage('SAST - Semgrep') {
// steps {
// sh """
// docker run --rm -v "\$(pwd):/src" \
// returntocorp/semgrep:latest \
// semgrep scan --config=auto --debug \
// --json --output /src/semgrep.json \
// /src/internal /src/cmd || true
//
// echo "After semgrep:"
// ls -la
// """
// }
// }
//
// stage('Upload to DefectDojo') {
// steps {
// sh """
// curl -X POST "${DD_URL}/api/v2/import-scan/" \
// -H "Authorization: Token ${DD_API_KEY}" \
// -F "scan_type=Semgrep JSON Report" \
// -F "file=@\$(pwd)/semgrep.json" \
// -F "product_name=Sendit" \
// -F "engagement_name=Jenkins-CI" \
// -F "auto_create_context=true" \
// -F "close_old_findings=true"
// """
// }
// }
stage('Build image') {
steps {
script {
def shortSha = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
env.IMAGE_TAG_SHA = shortSha
sh """
docker build \
--build-arg GIT_COMMIT=${IMAGE_TAG_SHA} \
-t ${IMAGE}:${IMAGE_TAG_SHA} .
"""
}
}
}
stage('Login to registry') {
steps {
withCredentials([usernamePassword(credentialsId: "${REGISTRY_CREDS}", usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS')]) {
sh 'echo "$REG_PASS" | docker login ${REGISTRY} -u "$REG_USER" --password-stdin'
}
}
}
stage('Push image') {
steps {
script {
sh "docker push ${IMAGE}:${IMAGE_TAG_SHA}"
def branch = (env.BRANCH_NAME ?: sh(script: 'git rev-parse --abbrev-ref HEAD', returnStdout: true).trim())
def safeBranch = branch.replaceAll('[^a-zA-Z0-9_.-]', '-')
sh """
docker tag ${IMAGE}:${IMAGE_TAG_SHA} ${IMAGE}:${safeBranch}
docker push ${IMAGE}:${safeBranch}
"""
if (branch == 'master') {
sh """
docker tag ${IMAGE}:${IMAGE_TAG_SHA} ${IMAGE}:latest
docker push ${IMAGE}:latest
"""
}
}
}
}
}
post {
always {
sh 'docker logout ${REGISTRY} || true'
sh 'docker image rm -f ${IMAGE}:${IMAGE_TAG_SHA} || true'
sh 'docker image prune -f || true'
// sh 'rm -f semgrep.sarif || true'
}
}
}

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"ResendIt/internal/api/middleware" "ResendIt/internal/api/middleware"
"ResendIt/internal/auth" "ResendIt/internal/auth"
"ResendIt/internal/config"
"ResendIt/internal/db" "ResendIt/internal/db"
"ResendIt/internal/file" "ResendIt/internal/file"
"ResendIt/internal/user" "ResendIt/internal/user"
@@ -15,6 +16,7 @@ import (
"html/template" "html/template"
"net/http" "net/http"
"os" "os"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@@ -31,16 +33,24 @@ func main() {
panic(fmt.Errorf("failed to connect database: %w", err)) panic(fmt.Errorf("failed to connect database: %w", err))
} }
err = dbCon.AutoMigrate(&user.User{}, &file.FileRecord{}) err = dbCon.AutoMigrate(&user.User{}, &file.FileRecord{}, &config.ConfigEntry{})
if err != nil { if err != nil {
fmt.Printf("Error migrating database: %v\n", err) fmt.Printf("Error migrating database: %v\n", err)
return return
} }
r := gin.Default() // create temp folder
path := "./tmp"
if os.IsExist(os.Mkdir(path, os.ModePerm)) {
fmt.Printf("Temp folder already exists, skipping creation\n")
} else {
if err := os.MkdirAll(path, os.ModePerm); err != nil {
fmt.Printf("Error creating temp folder: %v\n", err)
return
}
}
// CSRF: set a token cookie for browsers and enforce it on unsafe /api calls. r := gin.Default()
r.Use(middleware.EnsureCSRFCookie())
r.MaxMultipartMemory = 10 << 30 r.MaxMultipartMemory = 10 << 30
r.SetFuncMap(template.FuncMap{ r.SetFuncMap(template.FuncMap{
@@ -71,20 +81,37 @@ func main() {
userService := user.NewService(userRepo) userService := user.NewService(userRepo)
userHandler := user.NewHandler(userService) userHandler := user.NewHandler(userService)
configRepo := config.NewRepository(dbCon)
configService := config.NewService(configRepo)
if err := configService.EnsureDefaults(); err != nil {
panic(fmt.Errorf("failed to ensure config defaults: %w", err))
}
fileRepo := file.NewRepository(dbCon) fileRepo := file.NewRepository(dbCon)
fileService := file.NewService(fileRepo, "./uploads") fileService := file.NewService(fileRepo, "./uploads")
fileHandler := file.NewHandler(fileService) fileHandler := file.NewHandler(fileService, configService)
createAdminUser(userService) createAdminUser(userService)
apiRoute := r.Group("/api") apiRoute := r.Group("/api")
apiRoute.Use(middleware.CSRFMiddleware()) // General API rate limiting to reduce abuse/spam.
apiRoute.Use(middleware.RateLimitByIPDynamic(
func() int {
return configService.GetIntDefault(config.KeyRateLimitApiPerMinute, config.DefaultRateLimitApiPerMinute)
},
time.Minute,
func() int {
return configService.GetIntDefault(config.KeyRateLimitApiBurst, config.DefaultRateLimitApiBurst)
},
5*time.Minute,
))
auth.RegisterRoutes(apiRoute, authHandler) auth.RegisterRoutes(apiRoute, authHandler, configService)
user.RegisterRoutes(apiRoute, userHandler) user.RegisterRoutes(apiRoute, userHandler)
file.RegisterRoutes(apiRoute, fileHandler) file.RegisterRoutes(apiRoute, fileHandler)
webHandler := web.NewHandler(fileService) webHandler := web.NewHandler(fileService, configService)
web.RegisterRoutes(r, webHandler, userService) web.RegisterRoutes(r, webHandler, userService)
port := os.Getenv("PORT") port := os.Getenv("PORT")
@@ -92,6 +119,12 @@ func main() {
port = "8080" port = "8080"
} }
domain := os.Getenv("DOMAIN")
if domain == "" {
domain = "http://localhost:" + port + "/"
os.Setenv("DOMAIN", domain)
}
err = r.Run(":" + port) err = r.Run(":" + port)
if err != nil { if err != nil {
return return

View File

@@ -1,98 +0,0 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
)
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// EnsureCSRFCookie sets a csrf_token cookie if it's missing.
//
// Uses SameSite=Strict to reduce cross-site cookie sending.
// HttpOnly must be false so browser JS can read it and send it back in a header.
func EnsureCSRFCookie() gin.HandlerFunc {
return func(c *gin.Context) {
if tok, err := c.Cookie("csrf_token"); err != nil || tok == "" {
if tok, err := randomToken(32); err == nil {
secure := os.Getenv("USE_HTTPS") == "true"
http.SetCookie(c.Writer, &http.Cookie{
Name: "csrf_token",
Value: tok,
Path: "/",
Secure: secure,
HttpOnly: false,
SameSite: http.SameSiteStrictMode,
})
}
}
c.Next()
}
}
// CSRFMiddleware enforces CSRF checks on unsafe methods for cookie-authenticated requests.
//
// - Skips safe methods (GET/HEAD/OPTIONS).
// - Skips requests using Authorization: Bearer (non-cookie API clients).
// - Enforces only when auth_token cookie is present (browser session).
//
// Validation uses the double-submit cookie pattern:
// cookie csrf_token must match X-CSRF-Token header OR _csrf form field.
func CSRFMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
m := c.Request.Method
if m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions {
c.Next()
return
}
if strings.HasPrefix(c.GetHeader("Authorization"), "Bearer ") {
c.Next()
return
}
// Only enforce when cookie auth is in play
if _, err := c.Cookie("auth_token"); err != nil {
c.Next()
return
}
// Extra hardening: basic Origin check when present.
if origin := c.GetHeader("Origin"); origin != "" {
host := c.Request.Host
if !strings.Contains(origin, host) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "csrf origin blocked"})
return
}
}
cookieTok, err := c.Cookie("csrf_token")
if err != nil || cookieTok == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "missing csrf cookie"})
return
}
reqTok := c.GetHeader("X-CSRF-Token")
if reqTok == "" {
reqTok = c.PostForm("_csrf")
}
if reqTok == "" || reqTok != cookieTok {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "bad csrf token"})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,147 @@
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type tokenBucket struct {
mu sync.Mutex
rate float64 // tokens per second
burst float64 // max tokens
tokens float64
last time.Time
}
func newTokenBucket(max int, per time.Duration, burst int) *tokenBucket {
if burst <= 0 {
burst = max
}
rate := float64(max) / per.Seconds()
b := float64(burst)
now := time.Now()
return &tokenBucket{rate: rate, burst: b, tokens: b, last: now}
}
func (b *tokenBucket) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
delta := now.Sub(b.last).Seconds()
b.last = now
b.tokens += delta * b.rate
if b.tokens > b.burst {
b.tokens = b.burst
}
if b.tokens < 1 {
return false
}
b.tokens -= 1
return true
}
type ipClient struct {
bucket *tokenBucket
max int
burst int
lastSeen time.Time
}
// RateLimitByIP returns a Gin middleware that rate limits requests per client IP.
//
// max: max requests per time window (per)
// per: the time window duration
// burst: optional burst capacity (defaults to max if <=0)
// ttl: how long to keep idle IP buckets around
func RateLimitByIP(max int, per time.Duration, burst int, ttl time.Duration) gin.HandlerFunc {
return RateLimitByIPDynamic(func() int { return max }, per, func() int { return burst }, ttl)
}
// RateLimitByIPDynamic is like RateLimitByIP but reads max/burst dynamically.
// This allows changing limits at runtime (e.g. from an admin config page).
func RateLimitByIPDynamic(maxFn func() int, per time.Duration, burstFn func() int, ttl time.Duration) gin.HandlerFunc {
var (
mu sync.Mutex
clients = make(map[string]*ipClient)
)
// opportunistic cleanup (runs at most once per minute)
var (
cleanupMu sync.Mutex
lastCleanup time.Time
)
cleanup := func(now time.Time) {
cleanupMu.Lock()
defer cleanupMu.Unlock()
if !lastCleanup.IsZero() && now.Sub(lastCleanup) < time.Minute {
return
}
lastCleanup = now
mu.Lock()
defer mu.Unlock()
for ip, c := range clients {
if now.Sub(c.lastSeen) > ttl {
delete(clients, ip)
}
}
}
getClient := func(ip string, now time.Time, max int, burst int) *ipClient {
mu.Lock()
defer mu.Unlock()
c, ok := clients[ip]
if !ok {
c = &ipClient{bucket: newTokenBucket(max, per, burst), max: max, burst: burst, lastSeen: now}
clients[ip] = c
return c
}
c.lastSeen = now
// If settings changed, reset the bucket.
if c.max != max || c.burst != burst {
c.bucket = newTokenBucket(max, per, burst)
c.max = max
c.burst = burst
}
return c
}
return func(c *gin.Context) {
// Kinda a shitty fix
if c.FullPath() == "/api/files/upload/chunk" || c.FullPath() == "/api/files/upload/init" || c.FullPath() == "/api/files/upload/complete" {
c.Next()
return
}
now := time.Now()
cleanup(now)
ip := c.ClientIP()
max := maxFn()
if max <= 0 {
max = 1
}
burst := burstFn()
if burst <= 0 {
burst = max
}
client := getClient(ip, now, max, burst)
if !client.bucket.allow() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "rate limit exceeded",
})
return
}
c.Next()
}
}

View File

@@ -1,7 +1,6 @@
package auth package auth
import ( import (
"net/http"
"os" "os"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -50,17 +49,15 @@ func (h *Handler) Login(c *gin.Context) {
isSecure := os.Getenv("USE_HTTPS") == "true" isSecure := os.Getenv("USE_HTTPS") == "true"
// Use http.SetCookie so we can set SameSite. c.SetCookie(
http.SetCookie(c.Writer, &http.Cookie{ "auth_token",
Name: "auth_token", token,
Value: token, 3600*24,
Path: "/", "/",
Domain: os.Getenv("DOMAIN"), os.Getenv("DOMAIN"),
MaxAge: 3600 * 24, isSecure,
Secure: isSecure, true, // httpOnly (IMPORTANT)
HttpOnly: true, )
SameSite: http.SameSiteStrictMode,
})
c.JSON(200, gin.H{"token": token}) c.JSON(200, gin.H{"token": token})
} }

View File

@@ -2,14 +2,30 @@ package auth
import ( import (
"ResendIt/internal/api/middleware" "ResendIt/internal/api/middleware"
"ResendIt/internal/config"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func RegisterRoutes(r *gin.RouterGroup, h *Handler) { type ConfigService interface {
GetIntDefault(key string, def int) int
}
func RegisterRoutes(r *gin.RouterGroup, h *Handler, cfg ConfigService) {
auth := r.Group("/auth") auth := r.Group("/auth")
auth.POST("/login", h.Login) // Stricter rate limit on login to reduce brute-force / log spam.
auth.POST("/login", middleware.RateLimitByIPDynamic(
func() int {
return cfg.GetIntDefault(config.KeyRateLimitLoginPerMinute, config.DefaultRateLimitLoginPerMinute)
},
time.Minute,
func() int {
return cfg.GetIntDefault(config.KeyRateLimitLoginBurst, config.DefaultRateLimitLoginBurst)
},
15*time.Minute,
), h.Login)
protected := auth.Group("/") protected := auth.Group("/")
protected.Use(middleware.AuthMiddleware()) protected.Use(middleware.AuthMiddleware())

View File

@@ -0,0 +1,7 @@
package buildinfo
// Commit is the git commit SHA the binary was built from.
//
// Set at build time via:
// -ldflags "-X ResendIt/internal/buildinfo.Commit=<sha>"
var Commit = "dev"

View File

@@ -0,0 +1,58 @@
package config
import "strconv"
const (
KeyModtext = "modt"
KeyUploadMaxFileSizeBytes = "upload.max_file_size_bytes"
KeyUploadMultiMaxFiles = "upload.multi.max_files"
KeyUploadMaxHours = "upload.max_hours"
KeyRateLimitLoginPerMinute = "ratelimit.login.per_minute"
KeyRateLimitApiPerMinute = "ratelimit.api.per_minute"
KeyRateLimitApiBurst = "ratelimit.api.burst"
KeyRateLimitLoginBurst = "ratelimit.login.burst"
KeyUseNtfy = "use_ntfy"
KeyNtfyUrl = "ntfy.url"
KeyNtfyTopic = "ntfy.topic"
)
// Defaults (used when DB does not have an override)
const (
DefaultModt = "A_SERVICE_BY_BRAMMIE15"
DefaultUploadMaxFileSizeBytes int64 = 10 << 30 // 10 GiB (matches MaxMultipartMemory intent)
DefaultUploadMultiMaxFiles = 50
DefaultUploadMaxHours = 24 * 7 // 7 days
DefaultRateLimitLoginPerMinute = 5
DefaultRateLimitLoginBurst = 10
DefaultRateLimitApiPerMinute = 60
DefaultRateLimitApiBurst = 30
DefaultUseNtfy = 0
DefaultNtfyUrl = ""
DefaultNtfyTopic = ""
)
// DefaultKeyValues returns a map of config keys to their default string values, for use when initializing the database.
// Code duplication be dammed
func DefaultKeyValues() map[string]string {
return map[string]string{
KeyModtext: DefaultModt,
KeyUploadMaxFileSizeBytes: strconv.FormatInt(DefaultUploadMaxFileSizeBytes, 10),
KeyUploadMultiMaxFiles: strconv.Itoa(DefaultUploadMultiMaxFiles),
KeyUploadMaxHours: strconv.Itoa(DefaultUploadMaxHours),
KeyRateLimitLoginPerMinute: strconv.Itoa(DefaultRateLimitLoginPerMinute),
KeyRateLimitLoginBurst: strconv.Itoa(DefaultRateLimitLoginBurst),
KeyRateLimitApiPerMinute: strconv.Itoa(DefaultRateLimitApiPerMinute),
KeyRateLimitApiBurst: strconv.Itoa(DefaultRateLimitApiBurst),
KeyUseNtfy: strconv.Itoa(DefaultUseNtfy),
KeyNtfyUrl: DefaultNtfyUrl,
KeyNtfyTopic: DefaultNtfyTopic,
}
}

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

@@ -0,0 +1,18 @@
package config
import "time"
// ConfigEntry stores runtime-tunable configuration in the database.
// Values are stored as strings but helpers exist for ints/bools/durations.
//
// Example keys:
// - upload.max_file_size_bytes
// - upload.multi.max_files
// - upload.max_hours
// - ratelimit.login.per_minute
// - ratelimit.api.per_minute
type ConfigEntry struct {
Key string `gorm:"primaryKey;size:128"`
Value string `gorm:"size:2048"`
UpdatedAt time.Time
}

View File

@@ -0,0 +1,55 @@
package config
import (
"errors"
"gorm.io/gorm"
)
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Get(key string) (*ConfigEntry, error) {
var e ConfigEntry
if err := r.db.First(&e, "key = ?", key).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *Repository) Upsert(key, value string) error {
// Try update first, then insert if nothing updated.
res := r.db.Model(&ConfigEntry{}).Where("key = ?", key).Update("value", value)
if res.Error != nil {
return res.Error
}
if res.RowsAffected > 0 {
return nil
}
return r.db.Create(&ConfigEntry{Key: key, Value: value}).Error
}
func (r *Repository) List() ([]ConfigEntry, error) {
var entries []ConfigEntry
if err := r.db.Order("key asc").Find(&entries).Error; err != nil {
return nil, err
}
return entries, nil
}
func (r *Repository) CreateIfMissing(key, value string) error {
var e ConfigEntry
err := r.db.First(&e, "key = ?", key).Error
if err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.Create(&ConfigEntry{Key: key, Value: value}).Error
}
return err
}

134
internal/config/service.go Normal file
View File

@@ -0,0 +1,134 @@
package config
import (
"errors"
"strconv"
"sync"
"time"
"gorm.io/gorm"
)
type Service struct {
repo *Repository
// Small in-memory cache to avoid hitting the DB on every request.
// Updated on SetString; lazy-filled on first Get.
mu sync.RWMutex
cache map[string]string
}
func NewService(r *Repository) *Service {
return &Service{repo: r, cache: make(map[string]string)}
}
func (s *Service) EnsureDefaults() error {
for k, v := range DefaultKeyValues() {
if err := s.repo.CreateIfMissing(k, v); err != nil {
return err
}
}
return nil
}
func (s *Service) List() ([]ConfigEntry, error) {
entries, err := s.repo.List()
if err != nil {
return nil, err
}
// refresh cache snapshot
s.mu.Lock()
for _, e := range entries {
s.cache[e.Key] = e.Value
}
s.mu.Unlock()
return entries, nil
}
func (s *Service) SetString(key, value string) error {
if err := s.repo.Upsert(key, value); err != nil {
return err
}
s.mu.Lock()
s.cache[key] = value
s.mu.Unlock()
return nil
}
func (s *Service) GetStringDefault(key, def string) string {
s.mu.RLock()
v, ok := s.cache[key]
s.mu.RUnlock()
if ok {
if v == "" {
return def
}
return v
}
e, err := s.repo.Get(key)
if err != nil {
return def
}
s.mu.Lock()
s.cache[key] = e.Value
s.mu.Unlock()
if e.Value == "" {
return def
}
return e.Value
}
func (s *Service) GetIntDefault(key string, def int) int {
v := s.GetStringDefault(key, "")
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func (s *Service) GetInt64Default(key string, def int64) int64 {
v := s.GetStringDefault(key, "")
if v == "" {
return def
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return def
}
return n
}
func (s *Service) GetBoolDefault(key string, def bool) bool {
v := s.GetStringDefault(key, "")
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func (s *Service) GetDurationSecondsDefault(key string, def time.Duration) time.Duration {
v := s.GetStringDefault(key, "")
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return time.Duration(n) * time.Second
}
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}

View File

@@ -1,8 +1,14 @@
package file package file
import ( import (
"ResendIt/internal/config"
"ResendIt/internal/notify"
"ResendIt/internal/util"
"fmt" "fmt"
"io"
"log"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "time"
@@ -12,10 +18,19 @@ import (
type Handler struct { type Handler struct {
service *Service service *Service
configService ConfigService
} }
func NewHandler(s *Service) *Handler { // ConfigService is the small interface we need from the config package.
return &Handler{service: s} // Keeping it as an interface avoids import cycles and keeps file.Handler easy to test.
type ConfigService interface {
GetIntDefault(key string, def int) int
GetInt64Default(key string, def int64) int64
GetStringDefault(key string, def string) string
}
func NewHandler(s *Service, cfg ConfigService) *Handler {
return &Handler{service: s, configService: cfg}
} }
func (h *Handler) Upload(c *gin.Context) { func (h *Handler) Upload(c *gin.Context) {
@@ -31,6 +46,12 @@ func (h *Handler) Upload(c *gin.Context) {
return return
} }
maxSize := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes)
if file.Size > maxSize {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
return
}
f, err := file.Open() f, err := file.Open()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"})
@@ -45,6 +66,10 @@ func (h *Handler) Upload(c *gin.Context) {
if err != nil || hours <= 0 { if err != nil || hours <= 0 {
hours = 24 // default hours = 24 // default
} }
maxHours := h.configService.GetIntDefault(config.KeyUploadMaxHours, config.DefaultUploadMaxHours)
if hours > maxHours {
hours = maxHours
}
duration := time.Duration(hours) * time.Hour duration := time.Duration(hours) * time.Hour
@@ -59,6 +84,21 @@ func (h *Handler) Upload(c *gin.Context) {
return return
} }
enabled := h.configService.GetIntDefault(config.KeyUseNtfy, config.DefaultUseNtfy)
if enabled == 1 {
ntfyURL := h.configService.GetStringDefault(config.KeyNtfyUrl, "")
topic := h.configService.GetStringDefault(config.KeyNtfyTopic, config.DefaultNtfyTopic)
go func() {
title := "ReSendit: new upload"
msg := fmt.Sprintf("%s (%s)\nID: %s", record.Filename, util.HumanSize(record.Size), record.ID)
clickUrl := fmt.Sprintf("f/%s", record.ViewID)
if err := notify.Publish(ntfyURL, topic, title, msg, clickUrl); err != nil {
log.Printf("ntfy publish failed: %v", err)
}
}()
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"id": record.ID, "id": record.ID,
"deletion_id": record.DeletionID, "deletion_id": record.DeletionID,
@@ -74,30 +114,15 @@ func (h *Handler) View(c *gin.Context) {
record, err := h.service.DownloadFile(id) record, err := h.service.DownloadFile(id)
if err != nil { if err != nil {
c.HTML(http.StatusOK, "fileNotFound.html", nil) c.HTML(http.StatusOK, "error.html", nil)
return return
} }
name := util.SafeFilename(record.Filename)
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, record.Filename)) 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.File(record.Path) c.File(record.Path)
} }
func safeFilename(name string) string {
// keep it simple: drop control chars and quotes
out := make([]rune, 0, len(name))
for _, r := range name {
if r < 32 || r == 127 || r == '"' || r == '\\' {
continue
}
out = append(out, r)
}
if len(out) == 0 {
return "file"
}
return string(out)
}
func isXSSRisk(filename string) bool { func isXSSRisk(filename string) bool {
ext := filepath.Ext(filename) ext := filepath.Ext(filename)
switch ext { switch ext {
@@ -113,11 +138,11 @@ func (h *Handler) Download(c *gin.Context) {
record, err := h.service.DownloadFile(id) record, err := h.service.DownloadFile(id)
if err != nil { if err != nil {
c.HTML(http.StatusOK, "fileNotFound.html", nil) c.HTML(http.StatusOK, "error.html", nil)
return return
} }
name := util.SafeFilename(record.Filename)
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, record.Filename)) 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-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.Header("Content-Type", "application/octet-stream")
@@ -129,7 +154,7 @@ func (h *Handler) Delete(c *gin.Context) {
_, err := h.service.DeleteFileByDeletionID(id) _, err := h.service.DeleteFileByDeletionID(id)
if err != nil { if err != nil {
c.HTML(http.StatusOK, "fileNotFound.html", nil) c.HTML(http.StatusOK, "error.html", nil)
return return
} }
@@ -168,7 +193,7 @@ func (h *Handler) AdminDelete(c *gin.Context) {
return return
} }
c.Redirect(303, "/admin") c.Redirect(301, "/admin")
} }
func (h *Handler) AdminForceDelete(c *gin.Context) { func (h *Handler) AdminForceDelete(c *gin.Context) {
@@ -185,7 +210,7 @@ func (h *Handler) AdminForceDelete(c *gin.Context) {
return return
} }
c.Redirect(303, "/admin") c.Redirect(301, "/admin")
} }
func (h *Handler) Import(c *gin.Context) { func (h *Handler) Import(c *gin.Context) {
@@ -215,3 +240,161 @@ func (h *Handler) Export(c *gin.Context) {
c.JSON(http.StatusOK, records) c.JSON(http.StatusOK, records)
} }
// Chunked stuff
func (h *Handler) UploadInit(c *gin.Context) {
var req struct {
Filename string `json:"filename"`
TotalChunks int `json:"totalChunks"`
Size int64 `json:"size"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"})
return
}
fileID := util.RandomString(32)
// create temp folder
path := filepath.Join("tmp", fileID)
if err := os.MkdirAll(path, os.ModePerm); err != nil {
c.JSON(500, gin.H{"error": "failed to create temp dir"})
return
}
c.JSON(200, gin.H{
"fileId": fileID,
})
}
func (h *Handler) UploadChunk(c *gin.Context) {
fileID := c.GetHeader("fileId")
chunkIndex := c.GetHeader("chunkIndex")
if fileID == "" || chunkIndex == "" {
c.JSON(400, gin.H{"error": "missing headers"})
return
}
idx, err := strconv.Atoi(chunkIndex)
if err != nil {
c.JSON(400, gin.H{"error": "invalid chunkIndex"})
return
}
file, err := c.FormFile("chunk")
if err != nil {
c.JSON(400, gin.H{"error": "missing chunk"})
return
}
src, err := file.Open()
if err != nil {
c.JSON(500, gin.H{"error": "cannot open chunk"})
return
}
defer src.Close()
chunkPath := filepath.Join("tmp", fileID, fmt.Sprintf("chunk_%d", idx))
dst, err := os.Create(chunkPath)
if err != nil {
c.JSON(500, gin.H{"error": "cannot save chunk"})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
c.JSON(500, gin.H{"error": "write failed"})
return
}
c.JSON(200, gin.H{"status": "ok"})
}
func (h *Handler) UploadComplete(c *gin.Context) {
var req struct {
FileID string `json:"fileId"`
Filename string `json:"filename"`
TotalChunks int `json:"totalChunks"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"})
return
}
tmpDir := filepath.Join("tmp", req.FileID)
// create pipe to stream into your existing service
pr, pw := io.Pipe()
go func() {
defer pw.Close()
for i := 0; i < req.TotalChunks; i++ {
chunkPath := filepath.Join(tmpDir, fmt.Sprintf("chunk_%d", i))
f, err := os.Open(chunkPath)
if err != nil {
pw.CloseWithError(err)
return
}
if _, err := io.Copy(pw, f); err != nil {
f.Close()
pw.CloseWithError(err)
return
}
f.Close()
}
}()
// reuse your existing upload logic 👇
record, err := h.service.UploadFile(
req.Filename,
pr,
false,
24*time.Hour,
)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
// cleanup temp
_ = os.RemoveAll(tmpDir)
c.JSON(200, gin.H{
"id": record.ID,
"view_key": record.ViewID,
})
}
func (h *Handler) UploadStatus(c *gin.Context) {
fileID := c.Param("fileId")
dir := filepath.Join("tmp", fileID)
files, err := os.ReadDir(dir)
if err != nil {
c.JSON(404, gin.H{"error": "not found"})
return
}
var uploaded []int
for _, f := range files {
var idx int
_, err := fmt.Sscanf(f.Name(), "chunk_%d", &idx)
if err == nil {
uploaded = append(uploaded, idx)
}
}
c.JSON(200, gin.H{
"uploadedChunks": uploaded,
})
}

View File

@@ -10,11 +10,18 @@ func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
files := r.Group("/files") files := r.Group("/files")
files.POST("/upload", h.Upload) files.POST("/upload", h.Upload)
files.POST("/upload-multi", h.UploadMulti)
//files.GET("/download/:id", h.Download) //files.GET("/download/:id", h.Download)
files.GET("/view/:id", h.View) files.GET("/view/:id", h.View)
files.GET("/delete/:del_id", h.Delete) files.GET("/delete/:del_id", h.Delete)
// Chunked upload endpoints
files.POST("/upload/init", h.UploadInit)
files.POST("/upload/chunk", h.UploadChunk)
files.POST("/upload/complete", h.UploadComplete)
files.GET("/upload/status/:fileId", h.UploadStatus)
adminRoutes := files.Group("/admin") adminRoutes := files.Group("/admin")
adminRoutes.Use(middleware.AuthMiddleware()) adminRoutes.Use(middleware.AuthMiddleware())
adminRoutes.Use(middleware.RequireRole("admin")) adminRoutes.Use(middleware.RequireRole("admin"))
@@ -24,8 +31,8 @@ func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
adminRoutes.GET("/download/:id", h.AdminGet) adminRoutes.GET("/download/:id", h.AdminGet)
adminRoutes.POST("/delete/:id", h.AdminDelete) adminRoutes.GET("/delete/:id", h.AdminDelete)
adminRoutes.POST("/delete/fr/:id", h.AdminForceDelete) adminRoutes.GET("/delete/fr/:id", h.AdminForceDelete)
adminRoutes.POST("/import", h.Import) adminRoutes.POST("/import", h.Import)
adminRoutes.GET("/export", h.Export) adminRoutes.GET("/export", h.Export)

View File

@@ -0,0 +1,78 @@
package file
import (
"ResendIt/internal/config"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// UploadMulti accepts up to 10 files, zips them server-side, and returns a single download/view key.
func (h *Handler) UploadMulti(c *gin.Context) {
if err := c.Request.ParseMultipartForm(0); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid multipart form"})
return
}
files := form.File["files"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing files"})
return
}
maxFiles := h.configService.GetIntDefault(config.KeyUploadMultiMaxFiles, config.DefaultUploadMultiMaxFiles)
if len(files) > maxFiles {
c.JSON(http.StatusBadRequest, gin.H{"error": "too many files"})
return
}
maxSize := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes)
var total int64
for _, fh := range files {
if fh.Size > maxSize {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
return
}
total += fh.Size
if total > maxSize {
// crude guard against huge bundles; you can add a separate config later.
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "bundle too large"})
return
}
}
once := c.PostForm("once") == "true"
durationStr := c.PostForm("duration")
hours, err := strconv.Atoi(durationStr)
if err != nil || hours <= 0 {
hours = 24
}
maxHours := h.configService.GetIntDefault(config.KeyUploadMaxHours, config.DefaultUploadMaxHours)
if hours > maxHours {
hours = maxHours
}
duration := time.Duration(hours) * time.Hour
record, err := h.service.UploadBundle(files, 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,
"view_key": record.ViewID,
})
}

159
internal/file/zip.go Normal file
View File

@@ -0,0 +1,159 @@
package file
import (
"ResendIt/internal/util"
"archive/zip"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
func safeZipName(name string) string {
name = filepath.Base(name)
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.TrimSpace(name)
if name == "" || name == "." {
return "file"
}
return name
}
func dedupeName(name string, seen map[string]int) string {
if _, ok := seen[name]; !ok {
seen[name] = 1
return name
}
seen[name]++
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
return fmt.Sprintf("%s (%d)%s", base, seen[name], ext)
}
func cuteZipName(fileCount int) string {
adjective := util.RandomAdjective()
verb := util.RandomVerb()
thing := util.RandomThing()
return fmt.Sprintf("%d%s%s%s.zip", fileCount, adjective, verb, thing)
}
// UploadBundle zips multiple uploaded files into a single .zip stored on disk and tracked as one FileRecord.
func (s *Service) UploadBundle(files []*multipart.FileHeader, deleteAfterDownload bool, expiresAfter time.Duration) (*FileRecord, error) {
if len(files) == 0 {
return nil, errors.New("no files")
}
if len(files) > 50 {
return nil, errors.New("too many files (max 50)")
}
folderID := uuid.NewString()
folderPath := filepath.Join(s.storageDir, folderID)
if err := os.MkdirAll(folderPath, os.ModePerm); err != nil {
return nil, err
}
zipDiskName := uuid.NewString() + ".zip"
zipPath := filepath.Join(folderPath, zipDiskName)
out, err := os.Create(zipPath)
if err != nil {
return nil, err
}
defer func() {
_ = out.Close()
if err != nil {
_ = os.Remove(zipPath)
}
}()
zw := zip.NewWriter(out)
defer func() { _ = zw.Close() }()
seen := map[string]int{}
for _, fh := range files {
rc, openErr := fh.Open()
if openErr != nil {
err = openErr
return nil, openErr
}
name := dedupeName(safeZipName(fh.Filename), seen)
h, _ := zip.FileInfoHeader(dummyFileInfo{name: name, size: fh.Size, mod: time.Now()})
h.Name = name
h.Method = zip.Deflate
w, createErr := zw.CreateHeader(h)
if createErr != nil {
_ = rc.Close()
err = createErr
return nil, createErr
}
if _, copyErr := io.Copy(w, rc); copyErr != nil {
_ = rc.Close()
err = copyErr
return nil, copyErr
}
_ = rc.Close()
}
if closeErr := zw.Close(); closeErr != nil {
err = closeErr
return nil, closeErr
}
if closeErr := out.Close(); closeErr != nil {
err = closeErr
return nil, closeErr
}
zipDisplayName := cuteZipName(len(files))
f := &FileRecord{
ID: folderID,
DeletionID: uuid.NewString(),
ViewID: uuid.NewString(),
Filename: zipDisplayName,
Path: zipPath,
Size: fileSize(zipPath),
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(expiresAfter),
DeleteAfterDownload: deleteAfterDownload,
}
if err := s.repo.Create(f); err != nil {
return nil, err
}
return f, nil
}
func fileSize(path string) int64 {
st, err := os.Stat(path)
if err != nil {
return 0
}
return st.Size()
}
// dummyFileInfo provides minimal os.FileInfo for zip headers.
// This avoids relying on the underlying uploaded file having a real modtime.
// (zip.Writer can work without this too, but headers look nicer.)
type dummyFileInfo struct {
name string
size int64
mod time.Time
}
func (d dummyFileInfo) Name() string { return d.name }
func (d dummyFileInfo) Size() int64 { return d.size }
func (d dummyFileInfo) Mode() os.FileMode { return 0o644 }
func (d dummyFileInfo) ModTime() time.Time { return d.mod }
func (d dummyFileInfo) IsDir() bool { return false }
func (d dummyFileInfo) Sys() any { return nil }

39
internal/notify/ntfy.go Normal file
View File

@@ -0,0 +1,39 @@
package notify
import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
func Publish(ntfyURL, topic, title, message string, clickUrl string) error {
ntfyURL = strings.TrimRight(ntfyURL, "/")
if ntfyURL == "" || topic == "" {
return nil // nothing configured
}
req, err := http.NewRequest("POST", ntfyURL+"/"+topic, strings.NewReader(message))
if err != nil {
return err
}
if title != "" {
req.Header.Set("Title", title)
}
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
domain := os.Getenv("DOMAIN")
req.Header.Set("Click", fmt.Sprintf("%s%s", domain, clickUrl))
client := &http.Client{Timeout: 3 * time.Second}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("ntfy returned %s", res.Status)
}
return nil
}

View File

@@ -1,6 +1,10 @@
package util package util
import "fmt" import (
"fmt"
"math/rand"
"strings"
)
func HumanSize(size int64) string { func HumanSize(size int64) string {
const unit = 1024 const unit = 1024
@@ -17,3 +21,33 @@ func HumanSize(size int64) string {
"KMGTPE"[exp], "KMGTPE"[exp],
) )
} }
func SafeFilename(name string) string {
name = strings.TrimSpace(name)
out := make([]rune, 0, len(name))
for _, r := range name {
// block control chars (incl CR/LF/TAB), DEL, quotes, backslash
if r < 32 || r == 127 || r == '"' || r == '\\' {
continue
}
out = append(out, r)
}
if len(out) == 0 {
return "file"
}
// optional: cap length
if len(out) > 200 {
out = out[:200]
}
return string(out)
}
func RandomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}

View File

@@ -0,0 +1,47 @@
package util
import "math/rand"
var Adjectives = []string{
"Cool", "Super", "Hot", "Spicy", "Sneaky", "Sleepy", "Tiny", "Mega", "Cosmic", "Silly",
"Cursed", "Blessed", "Wiggly", "Giga", "Chonky", "Shiny", "Angry", "Happy", "Soft", "Turbo",
"Zany", "Snappy", "Fluffy", "Cranky", "Glitchy", "Bubbly", "Frosty", "Electric", "Jolly", "Mystic",
"Weird", "Chunky", "Psycho", "Cheesy", "Smelly", "Slippery", "Fiery", "Wacky", "Vivid", "Hyper",
"Soggy", "Grumpy", "Luminous", "Spooky", "Funky", "Twisted", "Nifty", "Prickly", "Velvet", "Epic",
"Glorious", "Majestic", "Quirky", "Radiant", "Sneaky", "Bouncy", "Mysterious", "Noodle", "Raging", "Zesty",
"Shimmering", "Fabled", "Plush", "Snazzy", "Stormy", "Gleaming", "Vibrant", "Odd", "Tasty", "Whimsical",
"Feral", "Clever", "Jumpy", "Dizzy", "Wicked", "Chilly", "Hasty", "Bizarre", "Snug", "Cheerful",
}
var Things = []string{
"Potato", "Griefers", "Raccoons", "Pigeons", "Wizards", "Ninjas", "Pickles", "Dragons", "Goblins", "Burgers",
"Pancakes", "Hamsters", "Bananas", "Comets", "Robots", "Cats", "Kiwis", "Frogs", "Cupcakes", "Sprites",
"Monsters", "Aliens", "Slimes", "Tacos", "Unicorns", "Ghosts", "Snails", "Vampires", "Donuts", "Owls",
"Zombies", "Mermaids", "Beavers", "Octopuses", "Chickens", "Penguins", "Mushrooms", "Felines", "Llamas", "Waffles",
"Baboons", "Dragettes", "Pixies", "Sharks", "Elephants", "Squirrels", "Gnomes", "Wombats", "Cacti", "Puppets",
"Koalas", "Moose", "Yeti", "Bats", "Crabs", "Otters", "Trolls", "Geckos", "Parrots", "Snakes",
"Sloths", "Clowns", "Jellyfish", "Froggies", "Dragoneers", "Nuggets", "Sprites", "Critters", "Knights", "Squids",
"Tigers", "Foxes", "Penguinos", "Burglebugs", "Clouds", "Fireflies", "Shrooms", "Mice", "Wizards", "Berries",
}
var Verbs = []string{
"Zoom", "Bonk", "Yeet", "Vibe", "Hack", "Spark", "Bounce", "Nibble", "Smuggle", "Cook",
"Flick", "Slap", "Whack", "Zap", "Blast", "Slam", "Twist", "Flip", "Slide", "Crash",
"Pop", "Fling", "Snatch", "Boing", "Sizzle", "Clap", "Roar", "Sniff", "Swoop", "Blink",
"Dodge", "Smash", "Roll", "Twirl", "Snore", "Drip", "Slurp", "Chomp", "Shuffle", "Juggle",
"Bounce", "Whirl", "Gush", "Spit", "Frolic", "Honk", "Wiggle", "Crackle", "Pounce", "Sprinkle",
"Slam", "Zoomerang", "Flop", "Squish", "Boop", "Whiz", "Flipflop", "Snip", "Glide", "Zapzap",
"Bop", "Wobble", "Fumble", "Twinkle", "Splash", "Dribble", "Clobber", "Whackadoo", "Bounceback", "Snizzle",
}
func RandomAdjective() string {
return Adjectives[rand.Intn(len(Adjectives))]
}
func RandomThing() string {
return Things[rand.Intn(len(Things))]
}
func RandomVerb() string {
return Verbs[rand.Intn(len(Verbs))]
}

186
internal/web/config.go Normal file
View File

@@ -0,0 +1,186 @@
package web
import (
"ResendIt/internal/config"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type ConfigPageData struct {
Success bool
Error string
MODT string
UploadMaxFileSizeMB int64
UploadMultiMaxFiles int
UploadMaxHours int
RateLimitLoginPerMinute int
RateLimitLoginBurst int
RateLimitApiPerMinute int
RateLimitApiBurst int
NtfyUse bool
NtfyUrl string
NtfyTopic string
}
// ConfigPage renders a modular admin config screen.
func (h *Handler) ConfigPage(c *gin.Context) {
cfg := h.configService
maxBytes := cfg.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes)
data := ConfigPageData{
Success: c.Query("saved") == "1",
MODT: cfg.GetStringDefault(config.KeyModtext, config.DefaultModt),
UploadMaxFileSizeMB: maxBytes / (1024 * 1024),
UploadMultiMaxFiles: cfg.GetIntDefault(config.KeyUploadMultiMaxFiles, config.DefaultUploadMultiMaxFiles),
UploadMaxHours: cfg.GetIntDefault(config.KeyUploadMaxHours, config.DefaultUploadMaxHours),
RateLimitLoginPerMinute: cfg.GetIntDefault(config.KeyRateLimitLoginPerMinute, config.DefaultRateLimitLoginPerMinute),
RateLimitLoginBurst: cfg.GetIntDefault(config.KeyRateLimitLoginBurst, config.DefaultRateLimitLoginBurst),
RateLimitApiPerMinute: cfg.GetIntDefault(config.KeyRateLimitApiPerMinute, config.DefaultRateLimitApiPerMinute),
RateLimitApiBurst: cfg.GetIntDefault(config.KeyRateLimitApiBurst, config.DefaultRateLimitApiBurst),
NtfyUse: cfg.GetIntDefault(config.KeyUseNtfy, 0) != 0,
NtfyUrl: cfg.GetStringDefault(config.KeyNtfyUrl, config.DefaultNtfyUrl),
NtfyTopic: cfg.GetStringDefault(config.KeyNtfyTopic, config.DefaultNtfyTopic),
}
c.HTML(http.StatusOK, "config.html", data)
}
func (h *Handler) ConfigSave(c *gin.Context) {
cfg := h.configService
// Parse + validate.
parseInt := func(name string, min, max int) (int, error) {
v := c.PostForm(name)
n, err := strconv.Atoi(v)
if err != nil {
return 0, err
}
if n < min {
n = min
}
if max > 0 && n > max {
n = max
}
return n, nil
}
parseInt64 := func(name string, min int64, max int64) (int64, error) {
v := c.PostForm(name)
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, err
}
if n < min {
n = min
}
if max > 0 && n > max {
n = max
}
return n, nil
}
newMODT, err := strconv.Unquote(`"` + c.PostForm("site_modt") + `"`)
if err != nil {
h.renderConfigError(c, "invalid modtext")
return
}
maxMB, err := parseInt64("upload_max_file_size_mb", 1, 1024*1024)
if err != nil {
h.renderConfigError(c, "invalid max file size")
return
}
maxFiles, err := parseInt("upload_multi_max_files", 1, 500)
if err != nil {
h.renderConfigError(c, "invalid max files")
return
}
maxHours, err := parseInt("upload_max_hours", 1, 24*365)
if err != nil {
h.renderConfigError(c, "invalid max hours")
return
}
// Rate limits: stored, but not applied dynamically yet.
loginPerMin, err := parseInt("ratelimit_login_per_minute", 1, 10000)
if err != nil {
h.renderConfigError(c, "invalid login rate")
return
}
loginBurst, err := parseInt("ratelimit_login_burst", 1, 10000)
if err != nil {
h.renderConfigError(c, "invalid login burst")
return
}
apiPerMin, err := parseInt("ratelimit_api_per_minute", 1, 100000)
if err != nil {
h.renderConfigError(c, "invalid api rate")
return
}
apiBurst, err := parseInt("ratelimit_api_burst", 1, 100000)
if err != nil {
h.renderConfigError(c, "invalid api burst")
return
}
useNTFY, err := strconv.ParseBool(c.PostForm("ntfy_use"))
if err != nil {
h.renderConfigError(c, "invalid ntfy use value")
return
}
ntfyUrl := c.PostForm("ntfy_url")
ntfyTopic := c.PostForm("ntfy_topic")
// Persist.
if err := cfg.SetString(config.KeyUploadMaxFileSizeBytes, strconv.FormatInt(maxMB*1024*1024, 10)); err != nil {
h.renderConfigError(c, err.Error())
return
}
if err := cfg.SetString(config.KeyUploadMultiMaxFiles, strconv.Itoa(maxFiles)); err != nil {
h.renderConfigError(c, err.Error())
return
}
if err := cfg.SetString(config.KeyUploadMaxHours, strconv.Itoa(maxHours)); err != nil {
h.renderConfigError(c, err.Error())
return
}
_ = cfg.SetString(config.KeyModtext, newMODT)
_ = cfg.SetString(config.KeyRateLimitLoginPerMinute, strconv.Itoa(loginPerMin))
_ = cfg.SetString(config.KeyRateLimitLoginBurst, strconv.Itoa(loginBurst))
_ = cfg.SetString(config.KeyRateLimitApiPerMinute, strconv.Itoa(apiPerMin))
_ = cfg.SetString(config.KeyRateLimitApiBurst, strconv.Itoa(apiBurst))
// shitty ah fix
actualBool := 0
if useNTFY {
actualBool = 1
}
_ = cfg.SetString(config.KeyUseNtfy, strconv.Itoa(actualBool))
_ = cfg.SetString(config.KeyNtfyUrl, ntfyUrl)
_ = cfg.SetString(config.KeyNtfyTopic, ntfyTopic)
c.Redirect(http.StatusFound, "/config?saved=1")
}
func (h *Handler) renderConfigError(c *gin.Context, msg string) {
maxBytes := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes)
data := ConfigPageData{
Error: msg,
UploadMaxFileSizeMB: maxBytes / (1024 * 1024),
UploadMultiMaxFiles: h.configService.GetIntDefault(config.KeyUploadMultiMaxFiles, config.DefaultUploadMultiMaxFiles),
UploadMaxHours: h.configService.GetIntDefault(config.KeyUploadMaxHours, config.DefaultUploadMaxHours),
RateLimitLoginPerMinute: h.configService.GetIntDefault(config.KeyRateLimitLoginPerMinute, config.DefaultRateLimitLoginPerMinute),
RateLimitLoginBurst: h.configService.GetIntDefault(config.KeyRateLimitLoginBurst, config.DefaultRateLimitLoginBurst),
RateLimitApiPerMinute: h.configService.GetIntDefault(config.KeyRateLimitApiPerMinute, config.DefaultRateLimitApiPerMinute),
RateLimitApiBurst: h.configService.GetIntDefault(config.KeyRateLimitApiBurst, config.DefaultRateLimitApiBurst),
}
c.HTML(http.StatusBadRequest, "config.html", data)
}

View File

@@ -1,7 +1,10 @@
package web package web
import ( import (
"ResendIt/internal/buildinfo"
"ResendIt/internal/config"
"ResendIt/internal/file" "ResendIt/internal/file"
"os"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -9,11 +12,24 @@ import (
type Handler struct { type Handler struct {
fileService *file.Service fileService *file.Service
configService ConfigService
} }
func NewHandler(fileService *file.Service) *Handler { type ConfigService interface {
GetIntDefault(key string, def int) int
GetInt64Default(key string, def int64) int64
GetStringDefault(key string, def string) string
//SetInt(key string, value int) error
//SetInt64(key string, value int64) error
//SetBool(key string, value bool) error
SetString(key string, value string) error
}
func NewHandler(fileService *file.Service, cfg ConfigService) *Handler {
return &Handler{ return &Handler{
fileService: fileService, fileService: fileService,
configService: cfg,
} }
} }
@@ -21,6 +37,7 @@ func NewHandler(fileService *file.Service) *Handler {
func (h *Handler) Index(c *gin.Context) { func (h *Handler) Index(c *gin.Context) {
c.HTML(200, "index.html", gin.H{ c.HTML(200, "index.html", gin.H{
"title": "Home", "title": "Home",
"MODT": h.configService.GetStringDefault(config.KeyModtext, config.DefaultModt),
}) })
} }
@@ -38,7 +55,9 @@ func (h *Handler) FileView(c *gin.Context) {
fileRecord, err := h.fileService.GetFileByViewID(id) fileRecord, err := h.fileService.GetFileByViewID(id)
if err != nil { if err != nil {
c.HTML(404, "fileNotFound.html", nil) c.HTML(404, "error.html", gin.H{
"MODT": h.configService.GetStringDefault(config.KeyModtext, config.DefaultModt),
})
return return
} }
@@ -46,6 +65,7 @@ func (h *Handler) FileView(c *gin.Context) {
deleteKey := fileRecord.DeletionID deleteKey := fileRecord.DeletionID
c.HTML(200, "complete.html", gin.H{ c.HTML(200, "complete.html", gin.H{
"MODT": h.configService.GetStringDefault(config.KeyModtext, config.DefaultModt),
"Filename": fileRecord.Filename, "Filename": fileRecord.Filename,
"DownloadID": downloadKey, "DownloadID": downloadKey,
"DeleteID": deleteKey, "DeleteID": deleteKey,
@@ -70,12 +90,38 @@ func (h *Handler) AdminPage(c *gin.Context) {
return return
} }
// Only check files on the current page.
// Status meanings:
// - green: file exists on disk
// - red: file missing
// - rainbow: stat error (something unexpected)
type AdminFileView struct {
file.FileRecord
ActualStatus string
}
adminFiles := make([]AdminFileView, 0, len(files))
for _, f := range files {
status := "red"
if f.Path != "" {
if _, err := os.Stat(f.Path); err == nil {
status = "green"
} else if os.IsNotExist(err) {
status = "red"
} else {
status = "rainbow"
}
}
adminFiles = append(adminFiles, AdminFileView{FileRecord: f, ActualStatus: status})
}
totalPages := (totalCount + limit - 1) / limit totalPages := (totalCount + limit - 1) / limit
c.HTML(200, "admin.html", gin.H{ c.HTML(200, "admin.html", gin.H{
"Files": files, "Files": adminFiles,
"Page": page, "Page": page,
"TotalPages": totalPages, "TotalPages": totalPages,
"BuildCommit": buildinfo.Commit,
}) })
} }

View File

@@ -20,6 +20,8 @@ func RegisterRoutes(r *gin.Engine, h *Handler, userService *user.Service) {
adminRoutes.Use(user.ForcePasswordChangeMiddleware(userService)) adminRoutes.Use(user.ForcePasswordChangeMiddleware(userService))
adminRoutes.GET("/admin", h.AdminPage) adminRoutes.GET("/admin", h.AdminPage)
adminRoutes.GET("/config", h.ConfigPage)
adminRoutes.POST("/config", h.ConfigSave)
adminRoutes.GET("/logout", h.Logout) adminRoutes.GET("/logout", h.Logout)
adminRoutes.GET("/change-password", h.ChangePasswordPage) adminRoutes.GET("/change-password", h.ChangePasswordPage)
} }

331
static/js/upload.js Normal file
View File

@@ -0,0 +1,331 @@
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 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;
const CHUNK_THRESHOLD = 1024 * 1024 * 1024;
const CHUNK_SIZE = 10 * 1024 * 1024;
const MAX_PARALLEL_UPLOADS = 4;
const MAX_RETRIES = 3;
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
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(Boolean).join(':');
}
function startUI() {
uploadBtn.disabled = true;
uploadBtn.innerText = "UPLOADING...";
cancelBtn.classList.remove('hidden');
progressContainer.classList.remove("hidden");
progressText.classList.remove("hidden");
document.getElementById("stats-text").classList.remove("hidden");
}
function updateProgress(loaded, total, startTime) {
const percent = Math.round((loaded / total) * 100);
progressBar.style.width = percent + "%";
progressText.innerText = percent + "%";
const elapsed = (Date.now() - startTime) / 1000;
if (elapsed <= 0) return;
const speed = loaded / elapsed;
const remaining = total - loaded;
speedText.innerText = formatBytes(speed) + "/S";
etaText.innerText = formatTime(remaining / speed);
}
function redirect(data) {
const key = data.view_key;
if (!key) {
alert("Invalid server response");
return;
}
window.location.href = "/f/" + key;
}
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 = () => {
const files = Array.from(input.files || []);
if (!files.length) {
uploadBtn.disabled = true;
return;
}
const total = files.reduce((a, f) => a + f.size, 0);
document.getElementById('dz-text').innerText =
files.length === 1
? `${files[0].name} [${formatBytes(files[0].size)}]`
: `${files.length} FILES [${formatBytes(total)}]`;
uploadBtn.disabled = false;
};
uploadBtn.onclick = () => {
const files = input.files;
if (!files.length) return;
if (files.length === 1 && files[0].size > CHUNK_THRESHOLD) {
uploadChunked(files[0]);
} else if (files.length === 1) {
uploadSingle(files[0]);
} else {
uploadMulti(files);
}
};
cancelBtn.onclick = e => {
e.stopPropagation();
if (currentXhr) currentXhr.abort();
localStorage.clear();
location.reload();
};
function commonFormData() {
const fd = new FormData();
fd.append("once", document.getElementById("once").checked ? "true" : "false");
fd.append("duration", parseInt(document.getElementById("duration").value, 10));
return fd;
}
function uploadSingle(file) {
startUI();
const fd = commonFormData();
fd.append("file", file);
const xhr = new XMLHttpRequest();
currentXhr = xhr;
const startTime = Date.now();
xhr.upload.onprogress = e => {
if (e.lengthComputable) updateProgress(e.loaded, file.size, startTime);
};
xhr.onload = () => {
try {
if (xhr.status < 200 || xhr.status >= 300) throw new Error();
redirect(JSON.parse(xhr.responseText));
} catch {
alert("Server error");
}
};
xhr.onerror = () => {
if (xhr.statusText !== "abort") {
alert("Upload failed");
location.reload();
}
};
xhr.open("POST", "/api/files/upload");
xhr.send(fd);
}
function uploadMulti(files) {
startUI();
const fd = commonFormData();
const list = Array.from(files);
list.forEach(f => fd.append("files", f));
const total = list.reduce((a, f) => a + f.size, 0);
const xhr = new XMLHttpRequest();
currentXhr = xhr;
const startTime = Date.now();
xhr.upload.onprogress = e => {
if (e.lengthComputable) updateProgress(e.loaded, total, startTime);
};
xhr.onload = () => {
try {
if (xhr.status < 200 || xhr.status >= 300) throw new Error();
redirect(JSON.parse(xhr.responseText));
} catch {
alert("Server error");
}
};
xhr.onerror = () => {
if (xhr.statusText !== "abort") {
alert("Upload failed");
location.reload();
}
};
xhr.open("POST", "/api/files/upload-multi");
xhr.send(fd);
}
async function uploadChunked(file) {
startUI();
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const initRes = await fetch("/api/files/upload/init", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
filename: file.name,
totalChunks,
size: file.size
})
});
const { fileId } = await initRes.json();
let uploadedBytes = 0;
const startTime = Date.now();
const chunks = Array.from({ length: totalChunks }, (_, i) => ({
index: i,
start: i * CHUNK_SIZE,
end: Math.min((i + 1) * CHUNK_SIZE, file.size),
retries: 0,
uploading: false,
done: false
}));
let active = 0;
let completed = 0;
function uploadChunk(chunk) {
return new Promise((res, rej) => {
const blob = file.slice(chunk.start, chunk.end);
const fd = new FormData();
fd.append("chunk", blob);
const xhr = new XMLHttpRequest();
currentXhr = xhr;
let last = 0;
xhr.upload.onprogress = e => {
if (!e.lengthComputable) return;
const delta = e.loaded - last;
last = e.loaded;
uploadedBytes += delta;
updateProgress(uploadedBytes, file.size, startTime);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
chunk.done = true;
completed++;
res();
} else {
rej();
}
};
xhr.onerror = rej;
xhr.open("POST", "/api/files/upload/chunk");
xhr.setRequestHeader("fileId", fileId);
xhr.setRequestHeader("chunkIndex", chunk.index);
xhr.send(fd);
});
}
return new Promise((resolve, reject) => {
function next() {
if (completed === totalChunks) return finish();
while (active < MAX_PARALLEL_UPLOADS) {
const chunk = chunks.find(c => !c.done && !c.uploading);
if (!chunk) break;
chunk.uploading = true;
active++;
uploadChunk(chunk)
.then(() => {
active--;
next();
})
.catch(() => {
active--;
if (chunk.retries++ < MAX_RETRIES) {
chunk.uploading = false;
} else {
reject();
}
next();
});
}
}
async function finish() {
try {
const res = await fetch("/api/files/upload/complete", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
fileId,
filename: file.name,
totalChunks
})
});
redirect(await res.json());
resolve();
} catch {
reject();
}
}
next();
});
}
function copy(id) {
const el = document.getElementById(id);
el.select();
document.execCommand('copy');
}

View File

@@ -16,6 +16,24 @@
td { border: 1px solid #000; padding: 10px; font-size: 13px; font-weight: 500; } td { border: 1px solid #000; padding: 10px; font-size: 13px; font-weight: 500; }
tr:hover { background: #ffff00; } tr:hover { background: #ffff00; }
/* Actual file status dot */
.dot { width: 12px; height: 12px; border: 2px solid #000; display: inline-block; }
.dot-green { background: #00ff00; }
.dot-red { background: #ff0000; }
.dot-rainbow {
animation: rainbow 0.8s linear infinite;
background: red;
}
@keyframes rainbow {
0% { background: #ff0000; }
16% { background: #ff9900; }
33% { background: #ffff00; }
50% { background: #00ff00; }
66% { background: #00ccff; }
83% { background: #9900ff; }
100% { background: #ff0000; }
}
/* Harsh Status Tags */ /* 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-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-deleted { background: #000; color: #ff0000; }
@@ -95,6 +113,7 @@
</div> </div>
<div class="flex flex-col items-end gap-2"> <div class="flex flex-col items-end gap-2">
<a href="/" class="nav-link">← BACK_TO_UPLOADER</a> <a href="/" class="nav-link">← BACK_TO_UPLOADER</a>
<a href="/config" class="nav-link">CONFIG_MODULE</a>
<a href="/logout" class="nav-link text-red-600">LOGOUT_SESSION</a> <a href="/logout" class="nav-link text-red-600">LOGOUT_SESSION</a>
</div> </div>
</header> </header>
@@ -109,12 +128,13 @@
<th>Hits</th> <th>Hits</th>
<th>Burn</th> <th>Burn</th>
<th>Status</th> <th>Status</th>
<th>Actual</th>
<th>System_Actions</th> <th>System_Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{if not .Files}} {{if not .Files}}
<tr><td colspan="7" class="text-center py-10 font-bold uppercase italic">Zero files in buffer</td></tr> <tr><td colspan="8" class="text-center py-10 font-bold uppercase italic">Zero files in buffer</td></tr>
{{end}} {{end}}
{{range .Files}} {{range .Files}}
<tr> <tr>
@@ -147,16 +167,24 @@
{{end}} {{end}}
</td> </td>
<td class="text-center">
{{if eq .ActualStatus "green"}}
<span class="dot dot-green" title="File exists"></span>
{{else if eq .ActualStatus "red"}}
<span class="dot dot-red" title="File missing"></span>
{{else}}
<span class="dot dot-rainbow" title="Stat error"></span>
{{end}}
</td>
<td> <td>
<div class="btn-group"> <div class="btn-group">
{{if not .Deleted}} {{if not .Deleted}}
<form action="/api/files/admin/delete/{{.ID}}" method="POST" 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.')">
<input type="hidden" name="_csrf" class="csrf-field">
<button type="submit" style="background: #ffcccc;">Terminate</button> <button type="submit" style="background: #ffcccc;">Terminate</button>
</form> </form>
{{end}} {{end}}
<form action="/api/files/admin/delete/fr/{{.ID}}" method="POST" 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.')">
<input type="hidden" name="_csrf" class="csrf-field">
<button type="submit">Full_Wipe</button> <button type="submit">Full_Wipe</button>
</form> </form>
</div> </div>
@@ -181,6 +209,9 @@
<div class="text-[12px] font-black uppercase"> <div class="text-[12px] font-black uppercase">
Data_Density: {{len .Files}} records | Page: {{.Page}}/{{.TotalPages}} Data_Density: {{len .Files}} records | Page: {{.Page}}/{{.TotalPages}}
</div> </div>
<div class="text-[11px] font-black uppercase text-gray-600">
Build_Commit: {{.BuildCommit}}
</div>
</footer> </footer>
</div> </div>
</div> </div>
@@ -204,13 +235,6 @@
currentForm = null; currentForm = null;
} }
// Fill CSRF hidden inputs from cookie (double-submit pattern)
(function fillCSRF() {
const m = document.cookie.match('(^|;)\\s*csrf_token\\s*=\\s*([^;]+)');
const tok = m ? m.pop() : '';
document.querySelectorAll('.csrf-field').forEach(el => el.value = tok);
})();
document.getElementById('modal-confirm-btn').addEventListener('click', () => { document.getElementById('modal-confirm-btn').addEventListener('click', () => {
if (currentForm) { if (currentForm) {
currentForm.submit(); currentForm.submit();

View File

@@ -258,12 +258,9 @@
} }
try { try {
const m = document.cookie.match('(^|;)\\s*csrf_token\\s*=\\s*([^;]+)');
const csrf = m ? m.pop() : '';
const res = await fetch('/api/user/change-password', { const res = await fetch('/api/user/change-password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
old_password: current, old_password: current,
new_password: nv new_password: nv

View File

@@ -6,105 +6,147 @@
<title>Send.it - File Ready</title> <title>Send.it - File Ready</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">
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style> <style>
/* No-design brutalist style */
* { * {
border-radius: 0 !important; border-radius: 0 !important;
transition: none !important; transition: none !important;
} }
body { body {
font-family: sans-serif; font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
background: #fff; background: #fff;
color: #000; color: #000;
} }
.box { .box {
border: 2px solid #000; border: 3px solid #000;
padding: 20px;
background: #fff; background: #fff;
width: 100%; width: 100%;
} }
.input-text { .input-text {
border: 1px solid #000; border: 2px solid #000;
padding: 4px 8px; padding: 6px 10px;
background: #fff; background: #fff;
width: 100%; width: 100%;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
font-size: 12px;
font-weight: 600;
} }
button { button {
border: 2px solid #000; border: 2px solid #000;
background: #eee; background: #fff;
padding: 4px 12px; padding: 6px 14px;
font-weight: bold; font-weight: 900;
font-size: 12px;
cursor: pointer; cursor: pointer;
text-transform: uppercase;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
box-shadow: 3px 3px 0px #000;
white-space: nowrap;
} }
button:hover { button:hover {
background: #ccc; background: #000;
color: #fff;
box-shadow: none;
transform: translate(2px, 2px);
} }
button:active { button:active {
background: #ffff00;
color: #000;
}
.section-label {
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.nav-link {
font-weight: 900;
text-decoration: underline;
text-transform: uppercase;
font-size: 11px;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
}
.nav-link:hover {
background: #000; background: #000;
color: #fff; color: #fff;
} }
</style> </style>
</head> </head>
<body class="min-h-screen flex items-center justify-center p-4"> <body class="min-h-screen flex flex-col items-center justify-center p-4">
<div class="w-full max-w-[493px] flex flex-col items-center"> <div class="w-full max-w-[520px] flex flex-col items-center">
<img src="/static/logo.png" alt="Send.it logo" style="width:50%;" class="mb-2 border-black"> <!-- Header -->
<header class="w-full mb-0 border-b-8 border-black pb-3 flex justify-between items-end">
<div class="box"> <div>
<img src="/static/logo.png" alt="Send.it logo" style="height:36px;" class="mb-1">
<header class="mb-6 border-b-2 border-black pb-2 text-center"> <h1 class="text-3xl font-black uppercase tracking-tighter leading-none">Send_It</h1>
<h1 class="text-xl font-bold uppercase">FILE READY</h1> </div>
</header> </header>
<div class="space-y-4"> <!-- Main Box -->
<div class="box">
<div class="p-5 space-y-4">
<div class="bg-black text-white p-2 text-xs font-bold"> <!-- Status Banner -->
UPLOAD COMPLETE <div class="border-2 border-black p-3" style="background:#00ff00;">
<span class="font-black text-sm uppercase">✓ File_Uploaded</span>
</div> </div>
<!-- &lt;!&ndash; File info &ndash;&gt;--> <!-- Download Link -->
<!-- <div class="text-[10px] uppercase font-bold">-->
<!-- example_file.png (2.4 MB)-->
<!-- </div>-->
<!-- Download -->
<div> <div>
<label class="text-[10px] font-bold block">DOWNLOAD LINK</label> <div class="section-label mb-1">Download_Link:</div>
<div class="flex"> <div class="flex">
<input id="res-url" readonly class="input-text text-sm"> <input id="res-url" readonly class="input-text">
<button onclick="copy('res-url')" class="border-l-0">COPY</button> <button onclick="copy('res-url')" class="border-l-0 pl-1 pr-1">COPY</button>
</div> </div>
</div> </div>
<!-- Delete --> <!-- Delete Link -->
<div> <div>
<label class="text-[10px] font-bold block">DELETION LINK (PRIVATE)</label> <div class="section-label mb-1 text-red-600">Deletion_Link <span class="text-gray-400 normal-case">(private)</span>:</div>
<div class="flex"> <div class="flex">
<input id="res-del" readonly class="input-text text-sm text-red-600"> <input id="res-del" readonly class="input-text" style="color:#cc0000;">
<button onclick="copy('res-del')" class="border-l-0">COPY</button> <button onclick="copy('res-del')" class="border-l-0 pl-1 pr-1">COPY</button>
</div>
</div>
<div>
<button id="qr-btn" onclick="toggleQR()" class="pl-1 pr-1">Show_QR</button>
<div id="qr-container" class="mt-3 hidden border-2 border-black p-4 flex justify-center">
<div id="qr-code"></div>
</div> </div>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="pt-4 flex justify-between"> <div class="border-t-2 border-black pt-4">
<a href="/" class="text-xs underline">NEW UPLOAD</a> <a href="/" class="nav-link">← New_Upload</a>
</div> </div>
</div> </div>
</div> </div>
<p class="mt-1 text-[10px] uppercase font-bold text-gray-400"> <!-- Footer -->
A service by Brammie15 <div class="w-full mt-3 flex justify-between items-center">
</p> <span class="text-[10px] font-black text-gray-400">{{ .MODT }}</span>
<div class="flex gap-4">
<a href="/static/TOS.txt" class="nav-link">TOS</a>
<a href="/admin" class="nav-link">SUDO</a>
</div>
</div>
</div> </div>
@@ -112,21 +154,36 @@
function copy(id) { function copy(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
el.select(); el.select();
el.setSelectionRange(0, 99999); // mobile support el.setSelectionRange(0, 99999);
document.execCommand('copy'); document.execCommand('copy');
} }
const downloadKey = "{{.DownloadID}}"; const downloadKey = "{{.DownloadID}}";
const deleteKey = "{{.DeleteID}}"; const deleteKey = "{{.DeleteID}}";
const base = window.location.origin; const base = window.location.origin;
document.getElementById("res-url").value = `${base}/api/files/view/${downloadKey}`; document.getElementById("res-url").value = `${base}/api/files/view/${downloadKey}`;
document.getElementById("res-del").value = `${base}/api/files/delete/${deleteKey}`; document.getElementById("res-del").value = `${base}/api/files/delete/${deleteKey}`;
</script>
<a href="/admin" class="fixed bottom-1 right-1 text-[10px] underline">SUDO</a> const downloadURL = `${base}/api/files/view/${downloadKey}`;
<a href="/static/TOS.txt" class="fixed bottom-1 left-1 text-[10px] underline">TOS</a>
// Generate QR code
new QRCode(document.getElementById("qr-code"), {
text: downloadURL,
width: 160,
height: 160
});
function toggleQR() {
const container = document.getElementById("qr-container");
const btn = document.getElementById("qr-btn");
const isHidden = container.classList.contains("hidden");
container.classList.toggle("hidden");
btn.textContent = isHidden ? "Hide_QR" : "Show_QR";
}
</script>
</body> </body>
</html> </html>

295
templates/config.html Normal file
View File

@@ -0,0 +1,295 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Config Module</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; padding: 20px; }
.section-title {
font-size: 18px;
font-weight: 900;
text-transform: uppercase;
border-bottom: 3px solid #000;
margin-bottom: 16px;
padding-bottom: 4px;
}
.row {
display: grid;
grid-template-columns: 300px 1fr;
gap: 10px;
margin-bottom: 12px;
align-items: center;
}
label {
font-weight: 900;
text-transform: uppercase;
font-size: 12px;
}
input {
border: 2px solid #000;
padding: 6px 8px;
font-size: 13px;
width: 100%;
}
.hint {
font-size: 11px;
font-style: italic;
margin-top: 4px;
border-left: 4px solid #000;
padding-left: 6px;
}
button, .button {
border: 2px solid #000;
background: #fff;
padding: 6px 12px;
cursor: pointer;
font-size: 11px;
font-weight: 900;
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;
}
.ok {
border: 3px solid #000;
background: #00ff00;
padding: 10px;
font-weight: 900;
text-transform: uppercase;
}
.err {
border: 3px solid #000;
background: #ff0000;
color: #fff;
padding: 10px;
font-weight: 900;
text-transform: uppercase;
}
</style>
</head>
<body>
<div class="max-w-4xl mx-auto">
<!-- HEADER -->
<header class="mb-6 border-b-8 border-black pb-4 flex justify-between items-start">
<h1 class="text-4xl font-black uppercase tracking-tighter leading-none">
Config_Settings
</h1>
<div class="flex flex-col items-end gap-2">
<a href="/admin" class="nav-link">Admin_Panel</a>
<a href="/config" class="nav-link">Reload_Config</a>
<a href="/logout" class="nav-link text-red-600">Logout</a>
</div>
</header>
<!-- STATUS -->
{{if .Success}}
<div class="ok mb-4">CONFIG_SAVED_SUCCESSFULLY</div>
{{end}}
{{if .Error}}
<div class="err mb-4">{{.Error}}</div>
{{end}}
<form method="POST" action="/config">
<div class="box mb-6">
<div class="section-title">General_Settings</div>
<div class="row">
<label>Site_MODT</label>
<input type="text" name="site_modt" value="{{.MODT}}">
</div>
</div>
<!-- UPLOAD SETTINGS -->
<div class="box mb-6">
<div class="section-title">Upload_Control</div>
<div class="row">
<label>Max_File_Size_MB</label>
<div class="flex flex-col gap-1">
<div class="flex gap-2 items-center">
<input id="fileSizeMB" type="number" min="1" name="upload_max_file_size_mb" value="{{.UploadMaxFileSizeMB}}">
<input id="fileSizeGB" type="text" readonly placeholder="GB"
class="w-24 bg-gray-100 cursor-not-allowed">
</div>
<div class="hint">Hard limit enforced by upload endpoints</div>
</div>
</div>
<div class="row">
<label>Multi_Upload_Limit</label>
<div>
<input type="number" min="1" name="upload_multi_max_files" value="{{.UploadMultiMaxFiles}}">
<div class="hint">Max files per multi-upload request</div>
</div>
</div>
<div class="row">
<label>Max_Expiry_Hours</label>
<div class="flex flex-col gap-1">
<div class="flex gap-2 items-center">
<input id="hoursInput" type="number" min="1" name="upload_max_hours" value="{{.UploadMaxHours}}">
<input id="daysOutput" type="text" readonly placeholder="Days" class="w-24 bg-gray-100 cursor-not-allowed">
</div>
<div class="hint">User duration capped to this value</div>
</div>
</div>
</div>
<!-- RATE LIMITS -->
<div class="box mb-6">
<div class="section-title">Rate_Limits_(Static)</div>
<div class="hint mb-4">
Changes require server restart to take effect
</div>
<div class="row">
<label>Login_Per_Minute</label>
<input type="number" min="1" name="ratelimit_login_per_minute" value="{{.RateLimitLoginPerMinute}}">
</div>
<div class="row">
<label>Login_Burst</label>
<input type="number" min="1" name="ratelimit_login_burst" value="{{.RateLimitLoginBurst}}">
</div>
<div class="row">
<label>API_Per_Minute</label>
<input type="number" min="1" name="ratelimit_api_per_minute" value="{{.RateLimitApiPerMinute}}">
</div>
<div class="row">
<label>API_Burst</label>
<input type="number" min="1" name="ratelimit_api_burst" value="{{.RateLimitApiBurst}}">
</div>
</div>
<div class="box mb-6">
<div class="section-title">NTFY_Settings</div>
<div class="row">
<label>Use_NTFY</label>
<input type="checkbox" id="ntfy_use_seen" {{if .NtfyUse}}checked{{end}}>
<input type="hidden" name="ntfy_use" id="ntfy_use_hidden" value="{{if .NtfyUse}}true{{else}}false{{end}}">
</div>
<div class="row">
<label>NTFY_Url</label>
<input type="text" name="ntfy_url" value="{{.NtfyUrl}}">
</div>
<div class="row">
<label>NTFY_Topic</label>
<input type="text" name="ntfy_topic" value="{{.NtfyTopic}}">
</div>
</div>
<!-- ACTIONS -->
<div class="flex justify-between items-center border-t-8 border-black pt-4">
<button type="submit">SAVE</button>
</div>
</form>
</div>
<a href="/change-password" class="fixed bottom-1 left-1 text-[10px] underline">
CHANGE_PASSWORD
</a>
<script>
function formatNumber(num) {
return parseFloat(num.toFixed(2));
}
function updateConversions() {
const mb = parseFloat(document.getElementById('fileSizeMB')?.value);
const gbField = document.getElementById('fileSizeGB');
if (!isNaN(mb) && gbField) {
gbField.value = formatNumber(mb / 1024) + " GB";
} else if (gbField) {
gbField.value = "";
}
const hours = parseFloat(document.getElementById('hoursInput')?.value);
const daysField = document.getElementById('daysOutput');
if (!isNaN(hours) && daysField) {
daysField.value = formatNumber(hours / 24) + " days";
} else if (daysField) {
daysField.value = "";
}
}
// Run on load
window.addEventListener('DOMContentLoaded', updateConversions);
// Listen for changes
document.addEventListener('input', updateConversions);
const checkbox = document.getElementById('ntfy_use_seen');
const hidden = document.getElementById('ntfy_use_hidden');
if (checkbox && hidden) {
// Update hidden input whenever checkbox changes
checkbox.addEventListener('change', () => {
hidden.value = checkbox.checked ? "true" : "false";
});
}
</script>
</body>
</html>

View File

@@ -6,7 +6,6 @@
<title>Nothing to see here</title> <title>Nothing to see here</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">
<style> <style>
* { * {
border-radius: 0 !important; border-radius: 0 !important;
@@ -14,48 +13,56 @@
} }
body { body {
font-family: sans-serif; font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
background: #fff; background: #fff;
color: #000; color: #000;
} }
.box { .box {
border: 2px solid #000; border: 3px solid #000;
padding: 20px; padding: 20px;
background: #fff; background: #fff;
width: 100%; width: 100%;
} }
.button { a.button {
border: 2px solid #000; border: 2px solid #000;
background: #eee; background: #fff;
padding: 4px 12px; padding: 6px 14px;
font-weight: bold; font-weight: 900;
font-size: 12px;
cursor: pointer; cursor: pointer;
text-transform: uppercase;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
box-shadow: 3px 3px 0px #000;
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
} }
.button:hover { a.button:hover {
background: #ccc;
}
.button:active {
background: #000; background: #000;
color: #fff; color: #fff;
box-shadow: none;
transform: translate(2px, 2px);
}
a.button:active {
background: #ffff00;
color: #000;
} }
.title { .title {
font-size: 28px; font-size: 28px;
font-weight: 900; font-weight: 900;
border-bottom: 2px solid #000; border-bottom: 3px solid #000;
margin-bottom: 10px; margin-bottom: 10px;
padding-bottom: 4px; padding-bottom: 4px;
text-transform: uppercase;
} }
.subtitle { .subtitle {
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: 900;
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -63,14 +70,35 @@
.text { .text {
font-size: 12px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700;
margin-bottom: 20px; margin-bottom: 20px;
} }
.nav-link {
font-weight: 900;
text-decoration: underline;
text-transform: uppercase;
font-size: 11px;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
}
.nav-link:hover {
background: #000;
color: #fff;
}
</style> </style>
</head> </head>
<body class="min-h-screen flex items-center justify-center p-4"> <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="w-full max-w-[493px] flex flex-col items-center">
<header class="w-full mb-0 border-b-8 border-black pb-3 flex justify-between items-end">
<div>
<img src="/static/logo.png" alt="Send.it logo" style="height:36px;" class="mb-1">
<h1 class="text-3xl font-black uppercase tracking-tighter leading-none">Send_It</h1>
</div>
</header>
<div class="box text-center"> <div class="box text-center">
<div class="title"> <div class="title">
@@ -93,6 +121,14 @@
</div> </div>
<div class="w-full mt-3 flex justify-between items-center">
<span class="text-[10px] font-black text-gray-400">{{.MODT}}</span>
<div class="flex gap-4">
<a href="/static/TOS.txt" class="nav-link">TOS</a>
<a href="/admin" class="nav-link">SUDO</a>
</div>
</div>
</div> </div>
</body> </body>

View File

@@ -7,47 +7,65 @@
<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">
<style> <style>
/* The "No-Design" Design */
* { * {
border-radius: 0 !important; border-radius: 0 !important;
transition: none !important; transition: none !important;
} }
body { body {
font-family: sans-serif; font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
background: #fff; background: #fff;
color: #000; color: #000;
} }
.box { .box {
border: 2px solid #000; border: 3px solid #000;
padding: 20px;
background: #fff; background: #fff;
width: 100%; width: 100%;
} }
.input-text { .input-text {
border: 1px solid #000; border: 2px solid #000;
padding: 4px 8px; padding: 6px 10px;
background: #fff; background: #fff;
width: 100%; width: 100%;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
font-size: 12px;
font-weight: 600;
}
select {
border: 2px solid #000;
padding: 5px 8px;
background: #fff;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
} }
button { button {
border: 2px solid #000; border: 2px solid #000;
background: #eee; background: #fff;
padding: 4px 12px; padding: 6px 14px;
font-weight: bold; font-weight: 900;
font-size: 12px;
cursor: pointer; cursor: pointer;
text-transform: uppercase;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
box-shadow: 3px 3px 0px #000;
} }
button:hover { button:hover {
background: #ccc; background: #000;
color: #fff;
box-shadow: none;
transform: translate(2px, 2px);
} }
button:active { button:active {
background: #000; background: #ffff00;
color: #fff; color: #000;
} }
button:disabled { button:disabled {
@@ -55,66 +73,150 @@
color: #999; color: #999;
border-color: #ccc; border-color: #ccc;
cursor: not-allowed; cursor: not-allowed;
box-shadow: none;
}
button:disabled:hover {
transform: none;
background: #f0f0f0;
color: #999;
} }
.btn-cancel { .btn-cancel {
background: #fff; background: #fff;
color: #cc0000; color: #cc0000;
border-color: #cc0000; border-color: #cc0000;
box-shadow: 3px 3px 0px #cc0000;
margin-top: 8px; margin-top: 8px;
width: 100%; width: 100%;
font-size: 10px; font-size: 11px;
} }
.btn-cancel:hover { .btn-cancel:hover {
background: #fee2e2; background: #cc0000;
color: #fff;
box-shadow: none;
} }
.drop-zone { .drop-zone {
border: 2px dashed #000; border: 3px dashed #000;
padding: 80px; padding: 60px 40px;
text-align: center; text-align: center;
background: #f9f9f9; background: #f9f9f9;
cursor: pointer; cursor: pointer;
} }
.drop-zone.active { .drop-zone.active {
background: #eee; background: #ffff00;
border-style: solid; border-style: solid;
} }
.burn-option { .drop-zone:hover {
background: #f0f0f0;
}
.burn-label {
color: #cc0000; color: #cc0000;
font-weight: bold; font-weight: 900;
font-size: 11px;
text-transform: uppercase;
}
.section-label {
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-tag {
font-weight: 900;
font-size: 11px;
padding: 3px 8px;
border: 2px solid #000;
display: inline-block;
text-transform: uppercase;
}
.status-success {
background: #00ff00;
color: #000;
}
.nav-link {
font-weight: 900;
text-decoration: underline;
text-transform: uppercase;
font-size: 11px;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
}
.nav-link:hover {
background: #000;
color: #fff;
}
input[type="checkbox"] {
width: 16px;
height: 16px;
border: 2px solid #000;
appearance: none;
background: #fff;
cursor: pointer;
position: relative;
flex-shrink: 0;
}
input[type="checkbox"]:checked {
background: #ff00ff;
}
input[type="checkbox"]:checked::after {
content: '✕';
position: absolute;
top: -2px;
left: 1px;
font-size: 12px; font-size: 12px;
font-weight: 900;
color: #fff;
} }
</style> </style>
</head> </head>
<body class="min-h-screen flex items-center justify-center p-4"> <body class="min-h-screen flex flex-col items-center justify-center p-4">
<!--<div class="w-full max-w-[493px] flex flex-col items-end">--> <div class="w-full max-w-[520px] flex flex-col items-center">
<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"> <!-- Header -->
<div class="box"> <header class="w-full mb-0 border-b-8 border-black pb-3 flex justify-between items-end">
<header class="mb-6 border-b-2 border-black pb-2 text-center"> <div>
<h1 class="text-xl font-bold uppercase">Send it</h1> <img src="/static/logo.png" alt="Send.it logo" style="height:36px;" class="mb-1">
<h1 class="text-3xl font-black uppercase tracking-tighter leading-none">Send_It</h1>
</div>
</header> </header>
<div id="upload-ui"> <!-- Main Box -->
<div id="drop-zone" class="drop-zone mb-4"> <div class="box">
<input type="file" id="fileInput" class="hidden">
<!-- Upload UI -->
<div id="upload-ui" class="p-5 space-y-4">
<!-- Drop Zone -->
<div id="drop-zone" class="drop-zone">
<input type="file" id="fileInput" class="hidden" multiple>
<div id="dz-content"> <div id="dz-content">
<span id="dz-text" class="text-sm">Click to select or drop file</span> <div class="text-2xl mb-2"></div>
<span id="dz-text" class="section-label text-gray-500">Click to select or drop file(s)</span>
</div> </div>
<div id="progress-container" class="hidden mt-3 border border-black h-4"> <!-- Progress Bar -->
<div id="progress-container" class="hidden mt-4 border-2 border-black h-5 bg-white">
<div id="progress-bar" class="h-full bg-black" style="width:0%"></div> <div id="progress-bar" class="h-full bg-black" style="width:0%"></div>
</div> </div>
<div class="flex justify-between items-center mt-1"> <div class="flex justify-between items-center mt-1">
<div id="progress-text" class="text-[10px] font-bold hidden">0%</div> <div id="progress-text" class="text-[10px] font-black hidden">0%</div>
<div id="stats-text" class="text-[10px] font-bold hidden uppercase"> <div id="stats-text" class="text-[10px] font-black hidden uppercase">
<span id="speed-text">0 KB/S</span> <span id="speed-text">0 KB/S</span>
<span class="mx-1 opacity-30">|</span> <span class="mx-1 opacity-30">|</span>
<span id="eta-text">--:--</span> <span id="eta-text">--:--</span>
@@ -122,216 +224,71 @@
</div> </div>
</div> </div>
<div class="space-y-4"> <!-- Config Row -->
<div class="flex items-center justify-between border-b border-black pb-2"> <div class="border-t-2 border-b-2 border-black py-3 space-y-3">
<label class="text-xs font-bold uppercase">Expire In:</label> <div class="flex items-center justify-between">
<select id="duration" class="border border-black text-xs p-1"> <span class="section-label">Expire_In:</span>
<option value="1">1 Hour</option> <select id="duration">
<option value="24">24 Hours</option> <option value="1">1_Hour</option>
<option value="168">7 Days</option> <option value="24">24_Hours</option>
<option value="730" selected>1 Month</option> <option value="168">7_Days</option>
<option value="730" selected>1_Month</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<input type="checkbox" id="once" class="w-4 h-4 border-black"> <input type="checkbox" id="once">
<label for="once" class="burn-option uppercase">Burn after</label> <label for="once" class="burn-label">Burn_After_Download</label>
</div>
<button id="uploadBtn" class="w-full" disabled>UPLOAD</button>
<button id="cancelBtn" class="btn-cancel hidden">CANCEL UPLOAD</button>
</div> </div>
</div> </div>
<div id="success-ui" class="hidden space-y-4"> <!-- Actions -->
<div class="bg-black text-white p-2 text-xs font-bold"> <div>
UPLOAD COMPLETE <button id="uploadBtn" class="w-full py-3 text-sm" disabled>UPLOAD_FILE</button>
<button id="cancelBtn" class="btn-cancel hidden">✕ CANCEL_UPLOAD</button>
</div>
</div>
<!-- Success UI -->
<div id="success-ui" class="hidden p-5 space-y-4">
<div class="border-2 border-black p-3" style="background:#00ff00;">
<span class="font-black text-sm uppercase">✓ Upload_Complete</span>
</div> </div>
<div> <div>
<label class="text-[10px] font-bold block">DOWNLOAD LINK</label> <div class="section-label mb-1">Download_Link:</div>
<div class="flex"> <div class="flex">
<input id="res-url" readonly class="input-text text-sm"> <input id="res-url" readonly class="input-text">
<button onclick="copy('res-url')" class="border-l-0">COPY</button> <button onclick="copy('res-url')" class="border-l-0 whitespace-nowrap">COPY</button>
</div> </div>
</div> </div>
<div> <div>
<label class="text-[10px] font-bold block">DELETION LINK (PRIVATE)</label> <div class="section-label mb-1 text-red-600">Deletion_Link <span class="text-gray-400">(private)</span>:</div>
<div class="flex"> <div class="flex">
<input id="res-del" readonly class="input-text text-sm text-red-600"> <input id="res-del" readonly class="input-text" style="color:#cc0000;">
<button onclick="copy('res-del')" class="border-l-0">COPY</button> <button onclick="copy('res-del')" class="border-l-0 whitespace-nowrap">COPY</button>
</div> </div>
</div> </div>
<div class="pt-4 flex justify-between"> <div class="border-t-2 border-black pt-3">
<button onclick="location.reload()" class="text-xs">NEW UPLOAD</button> <button onclick="location.reload()" class="text-xs">← New_Upload</button>
</div> </div>
</div> </div>
</div>
<p class="mt-1 text-[10px] uppercase font-bold text-gray-400">A service by Brammie15</p>
</div> </div>
<script> <!-- Footer -->
const zone = document.getElementById('drop-zone'); <div class="w-full mt-3 flex justify-between items-center">
const input = document.getElementById('fileInput'); <span class="text-[10px] font-black text-gray-400">{{.MODT}}</span>
const uploadBtn = document.getElementById('uploadBtn'); <div class="flex gap-4">
const cancelBtn = document.getElementById('cancelBtn'); <a href="/static/TOS.txt" class="nav-link">TOS</a>
<a href="/admin" class="nav-link">SUDO</a>
const progressText = document.getElementById("progress-text"); </div>
const statsText = document.getElementById("stats-text"); </div>
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);
// Redirect using view key
window.location.href = "/f/" + data.view_key;
} catch (err) {
console.error("Invalid response:", xhr.responseText);
alert("Server error");
}
} else {
alert("Upload failed");
}
};
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>
</div>
<script src="/static/js/upload.js"></script>
</body> </body>
</html> </html>

View File

@@ -10,64 +10,83 @@
* { border-radius: 0 !important; } * { border-radius: 0 !important; }
body { body {
font-family: sans-serif; font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
background: #fff; background: #fff;
color: #000; color: #000;
padding: 20px; padding: 20px;
} }
.box { .box {
border: 2px solid #000; border: 3px solid #000;
background: #fff; background: #fff;
} }
input { input {
border: 1px solid #000; border: 2px solid #000;
padding: 6px; padding: 8px;
font-size: 13px; font-size: 13px;
width: 100%; width: 100%;
background: #fff; background: #fff;
font-weight: bold;
} }
input:focus { input:focus {
outline: none; outline: none;
background: #f9f9f9; background: #ffff00;
} }
/* Chunky button style */
button { button {
border: 1px solid #000; border: 2px solid #000;
background: #eee; background: #fff;
padding: 4px 10px; padding: 6px 12px;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: 900;
text-transform: uppercase;
box-shadow: 3px 3px 0px #000;
} }
button:hover { button:hover {
background: #000; background: #000;
color: #fff; color: #fff;
box-shadow: none;
transform: translate(2px, 2px);
}
button:active {
background: #ff0000;
color: #fff;
} }
.nav-link { .nav-link {
font-weight: bold; font-weight: 900;
text-decoration: underline; text-decoration: underline;
font-size: 11px; text-transform: uppercase;
font-size: 12px;
}
.nav-link:hover {
background: #000;
color: #fff;
} }
.label { .label {
font-size: 10px; font-size: 10px;
font-weight: bold; font-weight: 900;
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 2px; margin-bottom: 4px;
} }
.error { .error {
border: 1px solid #000; border: 3px solid #000;
background: #ffcccc; background: #ff0000;
color: #fff;
font-size: 11px; font-size: 11px;
padding: 4px; padding: 6px;
margin-bottom: 10px; margin-bottom: 12px;
font-weight: bold; font-weight: 900;
text-transform: uppercase;
} }
</style> </style>
</head> </head>
@@ -75,26 +94,25 @@
<div class="max-w-md mx-auto"> <div class="max-w-md mx-auto">
<header class="mb-8 border-b-4 border-black pb-2 flex justify-between items-end"> <header class="mb-8 border-b-8 border-black pb-4 flex justify-between items-start">
<h1 class="text-3xl font-black uppercase tracking-tighter"> <h1 class="text-4xl font-black uppercase tracking-tighter leading-none">
System Access System_Access
</h1> </h1>
<a href="/" class="nav-link"> <a href="/" class="nav-link">
← BACK ← Back
</a> </a>
</header> </header>
<div class="box p-6">
<div class="box p-4">
{{if .Error}} {{if .Error}}
<div class="error"> <div class="error">
ACCESS DENIED ACCESS_DENIED
</div> </div>
{{end}} {{end}}
<form id="login-form" class="space-y-3"> <form id="login-form" class="space-y-4">
<div> <div>
<div class="label">Username</div> <div class="label">Username</div>
@@ -108,7 +126,7 @@
<div class="pt-2"> <div class="pt-2">
<button type="submit"> <button type="submit">
AUTHENTICATE Authenticate
</button> </button>
</div> </div>
@@ -127,14 +145,10 @@
const password = document.getElementById("password").value; const password = document.getElementById("password").value;
try { try {
const m = document.cookie.match('(^|;)\\s*csrf_token\\s*=\\s*([^;]+)');
const csrf = m ? m.pop() : '';
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json"
"X-CSRF-Token": csrf
}, },
body: JSON.stringify({ body: JSON.stringify({
username: username, username: username,
@@ -149,7 +163,6 @@
return; return;
} }
// Redirect to admin
window.location.href = "/admin"; window.location.href = "/admin";
} catch (err) { } catch (err) {
@@ -163,7 +176,7 @@
err = document.createElement("div"); err = document.createElement("div");
err.id = "error-box"; err.id = "error-box";
err.className = "error"; err.className = "error";
err.innerText = "ACCESS DENIED"; err.innerText = "ACCESS_DENIED";
form.prepend(err); form.prepend(err);
} }
} }