diff --git a/config/config.go b/config/config.go index 62afe24b..47a75aae 100644 --- a/config/config.go +++ b/config/config.go @@ -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"` @@ -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") @@ -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) @@ -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 { diff --git a/fsb.sample.env b/fsb.sample.env index 33f1dcea..226b5307 100644 --- a/fsb.sample.env +++ b/fsb.sample.env @@ -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 diff --git a/internal/routes/generate.go b/internal/routes/generate.go new file mode 100644 index 00000000..1b7d960a --- /dev/null +++ b/internal/routes/generate.go @@ -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, + }) +} diff --git a/internal/routes/stream.go b/internal/routes/stream.go index 1af0d401..5802b4c5 100644 --- a/internal/routes/stream.go +++ b/internal/routes/stream.go @@ -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) { @@ -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 diff --git a/internal/utils/hashing.go b/internal/utils/hashing.go index 4e128235..1b19c272 100644 --- a/internal/utils/hashing.go +++ b/internal/utils/hashing.go @@ -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 { @@ -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 +}