Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type config struct {
Port int `envconfig:"PORT" default:"8080"`
Host string `envconfig:"HOST" default:""`
HashLength int `envconfig:"HASH_LENGTH" default:"6"`
HMACSecret string `envconfig:"HMAC_SECRET" default:""`
UseSessionFile bool `envconfig:"USE_SESSION_FILE" default:"true"`
UserSession string `envconfig:"USER_SESSION"`
UsePublicIP bool `envconfig:"USE_PUBLIC_IP" default:"false"`
Expand Down Expand Up @@ -81,6 +82,7 @@ func SetFlagsFromConfig(cmd *cobra.Command) {
cmd.Flags().Int32("api-id", ValueOf.ApiID, "Telegram API ID")
cmd.Flags().String("api-hash", ValueOf.ApiHash, "Telegram API Hash")
cmd.Flags().String("bot-token", ValueOf.BotToken, "Telegram Bot Token")
cmd.Flags().String("hmac-secret", ValueOf.HMACSecret, "HMAC secret for signing stream URLs (defaults to bot token if unset)")
cmd.Flags().Int64("log-channel", ValueOf.LogChannelID, "Telegram Log Channel ID")
cmd.Flags().Bool("dev", ValueOf.Dev, "Enable development mode")
cmd.Flags().IntP("port", "p", ValueOf.Port, "Server port")
Expand Down Expand Up @@ -109,6 +111,10 @@ func (c *config) loadConfigFromArgs(log *zap.Logger, cmd *cobra.Command) {
if botToken != "" {
os.Setenv("BOT_TOKEN", botToken)
}
hmacSecret, _ := cmd.Flags().GetString("hmac-secret")
if hmacSecret != "" {
os.Setenv("HMAC_SECRET", hmacSecret)
}
logChannelID, _ := cmd.Flags().GetString("log-channel")
if logChannelID != "" {
os.Setenv("LOG_CHANNEL", logChannelID)
Expand Down Expand Up @@ -171,6 +177,9 @@ func (c *config) setupEnvVars(log *zap.Logger, cmd *cobra.Command) {
if err != nil {
log.Fatal("Error while parsing env variables", zap.Error(err))
}
if c.HMACSecret == "" {
c.HMACSecret = c.BotToken
}
var ipBlocked bool
ip, err := getIP(c.UsePublicIP)
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions fsb.sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ USE_SESSION_FILE=true
# Optional allowlist of Telegram user IDs (comma-separated)
# ALLOWED_USERS=12345,67890

# HMAC secret used to sign and verify stream URLs.
# If unset, defaults to BOT_TOKEN. Set this to a random secret value.
# HMAC_SECRET=

# Stream performance tuning
# STREAM_CONCURRENCY = parallel Telegram chunk downloads per stream request
# STREAM_BUFFER_COUNT = how many chunks to keep ready in memory
Expand Down
94 changes: 94 additions & 0 deletions internal/routes/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package routes

import (
"EverythingSuckz/fsb/config"
"EverythingSuckz/fsb/internal/utils"
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"

"github.com/gin-gonic/gin"
)

func (e *allRoutes) LoadGenerate(r *Route) {
genLog := e.log.Named("Generate")
defer genLog.Info("Loaded generate route")
r.Engine.GET("/generate/:messageID", getGenerateRoute)
}

// writeJSON writes a JSON response without HTML-escaping special chars like &.
func writeJSON(ctx *gin.Context, status int, v any) {
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
enc.Encode(v)
ctx.Data(status, "application/json; charset=utf-8", buf.Bytes())
}

func getGenerateRoute(ctx *gin.Context) {
messageIDParam := ctx.Param("messageID")
messageID, err := strconv.Atoi(messageIDParam)
if err != nil {
writeJSON(ctx, http.StatusBadRequest, gin.H{"ok": false, "error": "invalid message ID"})
return
}

secret := ctx.Query("secret")
if secret == "" {
secret = ctx.GetHeader("X-HMAC-Secret")
}
if secret == "" || secret != config.ValueOf.HMACSecret {
writeJSON(ctx, http.StatusUnauthorized, gin.H{"ok": false, "error": "unauthorized"})
return
}

expParam := ctx.Query("exp")
var expiresAt int64
var expiryLabel string

if expParam == "0" {
expiresAt = 0
expiryLabel = "never"
} else {
var expiryDuration time.Duration
if expParam != "" {
d, err := time.ParseDuration(expParam)
if err != nil {
writeJSON(ctx, http.StatusBadRequest, gin.H{"ok": false, "error": fmt.Sprintf("invalid exp duration: %s", expParam)})
return
}
if d <= 0 && expParam != "0" {
writeJSON(ctx, http.StatusBadRequest, gin.H{"ok": false, "error": "invalid exp duration"})
return
}
expiryDuration = d
} else {
expiryDuration = 24 * time.Hour
}
expiresAt = time.Now().Add(expiryDuration).Unix()
expiryLabel = expiryDuration.String()
}

sig := utils.SignURL(messageID, expiresAt)
link := fmt.Sprintf("%s/stream/%d?exp=%s&sig=%s",
config.ValueOf.Host,
messageID,
strconv.FormatInt(expiresAt, 10),
sig,
)

if ctx.Query("redirect") == "true" {
ctx.Redirect(http.StatusFound, link)
return
}

writeJSON(ctx, http.StatusOK, gin.H{
"ok": true,
"url": link,
"expires_at": expiresAt,
"expires_in": expiryLabel,
})
}
40 changes: 29 additions & 11 deletions internal/routes/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,26 @@ func getStreamRoute(ctx *gin.Context) {
}

authHash := ctx.Query("hash")
if authHash == "" {
http.Error(w, "missing hash param", http.StatusBadRequest)
expParam := ctx.Query("exp")

if expParam == "" && authHash == "" {
http.Error(w, "missing auth: provide hash or exp+sig", http.StatusBadRequest)
return
}

// If exp is present, verify signature early before loading file
if expParam != "" {
sig := ctx.Query("sig")
if sig == "" {
http.Error(w, "missing sig param", http.StatusBadRequest)
return
}
if reason, ok := utils.VerifyURL(sig, messageID, expParam); !ok {
http.Error(w, reason, http.StatusForbidden)
return
}
}

worker := bot.GetNextWorker()

file, err := utils.TimeFuncWithResult(log, "FileFromMessage", func() (*types.File, error) {
Expand All @@ -52,15 +67,18 @@ func getStreamRoute(ctx *gin.Context) {
return
}

expectedHash := utils.PackFile(
file.FileName,
file.FileSize,
file.MimeType,
file.ID,
)
if !utils.CheckHash(authHash, expectedHash) {
http.Error(w, "invalid hash", http.StatusBadRequest)
return
// If no exp, validate via hash
if expParam == "" {
expectedHash := utils.PackFile(
file.FileName,
file.FileSize,
file.MimeType,
file.ID,
)
if !utils.CheckHash(authHash, expectedHash) {
http.Error(w, "invalid hash", http.StatusBadRequest)
return
}
}

// for photo messages
Expand Down
38 changes: 38 additions & 0 deletions internal/utils/hashing.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ package utils
import (
"EverythingSuckz/fsb/config"
"EverythingSuckz/fsb/internal/types"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"time"
)

func PackFile(fileName string, fileSize int64, mimeType string, fileID int64) string {
Expand All @@ -16,3 +22,35 @@ func GetShortHash(fullHash string) string {
func CheckHash(inputHash string, expectedHash string) bool {
return inputHash == GetShortHash(expectedHash)
}

// SignURL generates an HMAC-SHA256 signature for messageID:expiry.
func SignURL(messageID int, expiry int64) string {
payload := fmt.Sprintf("%d:%d", messageID, expiry)
mac := hmac.New(sha256.New, []byte(config.ValueOf.HMACSecret))
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
const minSecureSigHexLen = 32 // 128-bit minimum
n := config.ValueOf.HashLength
if n < minSecureSigHexLen {
n = minSecureSigHexLen
}
if n > len(sig) {
n = len(sig)
}
return sig[:n]
}

func VerifyURL(sig string, messageID int, expiryStr string) (string, bool) {
expiry, err := strconv.ParseInt(expiryStr, 10, 64)
if err != nil {
return "invalid expiry", false
}
if expiry != 0 && time.Now().Unix() >= expiry {
return "link has expired", false
}
expected := SignURL(messageID, expiry)
if !hmac.Equal([]byte(sig), []byte(expected)) {
return "invalid signature", false
}
return "", true
}