Browse Source

add support for token generation

master
Nicolas Massé 6 years ago
parent
commit
9d2de87253
  1. 106
      bot.go
  2. 6
      go.mod
  3. 14
      go.sum
  4. 205
      http.go
  5. 199
      main.go
  6. 32
      secret.go
  7. 35
      secret_test.go
  8. 308
      security.go
  9. 91
      token.go
  10. 49
      token_test.go
  11. 38
      user.go
  12. 205
      web.go

106
bot.go

@ -5,10 +5,10 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"time" "time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
"github.com/spf13/viper"
) )
type PhotoBot struct { type PhotoBot struct {
@ -18,11 +18,37 @@ type PhotoBot struct {
} }
type TelegramBackend struct { type TelegramBackend struct {
TokenGenerator *TokenGenerator
WebPublicURL string
ChatDB *ChatDB ChatDB *ChatDB
AuthorizedUsers map[string]bool AuthorizedUsers map[string]bool
RetryDelay time.Duration RetryDelay time.Duration
NewUpdateTimeout int NewUpdateTimeout int
API *tgbotapi.BotAPI API *tgbotapi.BotAPI
Commands TelegramCommands
Messages TelegramMessages
}
type TelegramCommands struct {
Help string
NewAlbum string
Info string
Share string
}
type TelegramMessages struct {
Forbidden string
Help string
MissingAlbumName string
ServerError string
AlbumCreated string
DoNotUnderstand string
Info string
InfoNoAlbum string
NoUsername string
ThankYouMedia string
SharedAlbum string
SharedGlobal string
} }
func InitBot(targetDir string) *PhotoBot { func InitBot(targetDir string) *PhotoBot {
@ -69,12 +95,12 @@ func (bot *PhotoBot) ProcessUpdate(update tgbotapi.Update) {
username := update.Message.From.UserName username := update.Message.From.UserName
if username == "" { if username == "" {
bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgNoUsername")) bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.NoUsername)
return return
} }
if !bot.Telegram.AuthorizedUsers[username] { if !bot.Telegram.AuthorizedUsers[username] {
log.Printf("[%s] unauthorized user", username) log.Printf("[%s] unauthorized user", username)
bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgForbidden")) bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.Forbidden)
return return
} }
@ -87,41 +113,41 @@ func (bot *PhotoBot) ProcessUpdate(update tgbotapi.Update) {
if update.Message.IsCommand() { if update.Message.IsCommand() {
log.Printf("[%s] command: %s", username, text) log.Printf("[%s] command: %s", username, text)
switch update.Message.Command() { switch update.Message.Command() {
case "start", "aide", "help": case "start", bot.Telegram.Commands.Help:
bot.handleHelpCommand(update.Message) bot.handleHelpCommand(update.Message)
case "nouvelAlbum": case bot.Telegram.Commands.Share:
bot.handleShareCommand(update.Message)
case bot.Telegram.Commands.NewAlbum:
bot.handleNewAlbumCommand(update.Message) bot.handleNewAlbumCommand(update.Message)
case "info": case bot.Telegram.Commands.Info:
bot.handleInfoCommand(update.Message) bot.handleInfoCommand(update.Message)
case "pourLouise":
bot.Telegram.replyWithForcedReply(update.Message, viper.GetString("MsgSendMeSomething"))
default: default:
bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgDoNotUnderstand")) bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.DoNotUnderstand)
} }
} else { } else {
bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgDoNotUnderstand")) bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.DoNotUnderstand)
} }
} else if update.Message.Photo != nil { } else if update.Message.Photo != nil {
err := bot.handlePhoto(update.Message) err := bot.handlePhoto(update.Message)
if err != nil { if err != nil {
log.Printf("[%s] cannot add photo to current album: %s", username, err) log.Printf("[%s] cannot add photo to current album: %s", username, err)
bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgServerError")) bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.ServerError)
return return
} }
bot.dispatchMessage(update.Message) bot.dispatchMessage(update.Message)
bot.Telegram.replyWithMessage(update.Message, viper.GetString("MsgThankYouMedia")) bot.Telegram.replyWithMessage(update.Message, bot.Telegram.Messages.ThankYouMedia)
} else if update.Message.Video != nil { } else if update.Message.Video != nil {
err := bot.handleVideo(update.Message) err := bot.handleVideo(update.Message)
if err != nil { if err != nil {
log.Printf("[%s] cannot add video to current album: %s", username, err) log.Printf("[%s] cannot add video to current album: %s", username, err)
bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgServerError")) bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.ServerError)
return return
} }
bot.dispatchMessage(update.Message) bot.dispatchMessage(update.Message)
bot.Telegram.replyWithMessage(update.Message, viper.GetString("MsgThankYouMedia")) bot.Telegram.replyWithMessage(update.Message, bot.Telegram.Messages.ThankYouMedia)
} else { } else {
log.Printf("[%s] cannot handle this type of message", username) log.Printf("[%s] cannot handle this type of message", username)
bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgDoNotUnderstand")) bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.DoNotUnderstand)
} }
} }
@ -243,27 +269,51 @@ func (bot *PhotoBot) handleVideo(message *tgbotapi.Message) error {
} }
func (bot *PhotoBot) handleHelpCommand(message *tgbotapi.Message) { func (bot *PhotoBot) handleHelpCommand(message *tgbotapi.Message) {
bot.Telegram.replyWithMessage(message, viper.GetString("MsgHelp")) bot.Telegram.replyWithMessage(message, bot.Telegram.Messages.Help)
}
func (bot *PhotoBot) handleShareCommand(message *tgbotapi.Message) {
var tokenData TokenData = TokenData{
Timestamp: time.Now(),
Username: message.From.UserName,
}
if len(message.Text) < len(bot.Telegram.Commands.Share)+3 {
// Global share
tokenData.Entitlement = ""
token := bot.Telegram.TokenGenerator.NewToken(tokenData)
url := fmt.Sprintf("%s/s/%s/%s/album/", bot.Telegram.WebPublicURL, url.PathEscape(message.From.UserName), url.PathEscape(token))
bot.Telegram.replyWithMessage(message, bot.Telegram.Messages.SharedGlobal)
bot.Telegram.replyWithMessage(message, url)
} else {
// Album share
albumName := message.CommandArguments()
tokenData.Entitlement = albumName
token := bot.Telegram.TokenGenerator.NewToken(tokenData)
url := fmt.Sprintf("%s/s/%s/%s/album/%s/", bot.Telegram.WebPublicURL, url.PathEscape(message.From.UserName), url.PathEscape(token), url.PathEscape(albumName))
bot.Telegram.replyWithMessage(message, fmt.Sprintf(bot.Telegram.Messages.SharedAlbum, albumName))
bot.Telegram.replyWithMessage(message, url)
}
} }
func (bot *PhotoBot) handleInfoCommand(message *tgbotapi.Message) { func (bot *PhotoBot) handleInfoCommand(message *tgbotapi.Message) {
album, err := bot.MediaStore.GetCurrentAlbum() album, err := bot.MediaStore.GetCurrentAlbum()
if err != nil { if err != nil {
log.Printf("[%s] cannot get current album: %s", message.From.UserName, err) log.Printf("[%s] cannot get current album: %s", message.From.UserName, err)
bot.Telegram.replyToCommandWithMessage(message, viper.GetString("MsgServerError")) bot.Telegram.replyToCommandWithMessage(message, bot.Telegram.Messages.ServerError)
return return
} }
if album.Title != "" { if album.Title != "" {
bot.Telegram.replyWithMessage(message, fmt.Sprintf(viper.GetString("MsgInfo"), album.Title)) bot.Telegram.replyWithMessage(message, fmt.Sprintf(bot.Telegram.Messages.Info, album.Title))
} else { } else {
bot.Telegram.replyWithMessage(message, viper.GetString("MsgInfoNoAlbum")) bot.Telegram.replyWithMessage(message, bot.Telegram.Messages.InfoNoAlbum)
} }
} }
func (bot *PhotoBot) handleNewAlbumCommand(message *tgbotapi.Message) { func (bot *PhotoBot) handleNewAlbumCommand(message *tgbotapi.Message) {
if len(message.Text) < 14 { if len(message.Text) < len(bot.Telegram.Commands.NewAlbum)+3 {
bot.Telegram.replyToCommandWithMessage(message, viper.GetString("MsgMissingAlbumName")) bot.Telegram.replyToCommandWithMessage(message, bot.Telegram.Messages.MissingAlbumName)
return return
} }
albumName := message.CommandArguments() albumName := message.CommandArguments()
@ -271,11 +321,11 @@ func (bot *PhotoBot) handleNewAlbumCommand(message *tgbotapi.Message) {
err := bot.MediaStore.NewAlbum(albumName) err := bot.MediaStore.NewAlbum(albumName)
if err != nil { if err != nil {
log.Printf("[%s] cannot create album '%s': %s", message.From.UserName, albumName, err) log.Printf("[%s] cannot create album '%s': %s", message.From.UserName, albumName, err)
bot.Telegram.replyToCommandWithMessage(message, viper.GetString("MsgServerError")) bot.Telegram.replyToCommandWithMessage(message, bot.Telegram.Messages.ServerError)
return return
} }
bot.Telegram.replyWithMessage(message, viper.GetString("MsgAlbumCreated")) bot.Telegram.replyWithMessage(message, bot.Telegram.Messages.AlbumCreated)
} }
func (telegram *TelegramBackend) replyToCommandWithMessage(message *tgbotapi.Message, text string) error { func (telegram *TelegramBackend) replyToCommandWithMessage(message *tgbotapi.Message, text string) error {
@ -290,13 +340,3 @@ func (telegram *TelegramBackend) replyWithMessage(message *tgbotapi.Message, tex
_, err := telegram.API.Send(msg) _, err := telegram.API.Send(msg)
return err return err
} }
func (telegram *TelegramBackend) replyWithForcedReply(message *tgbotapi.Message, text string) error {
msg := tgbotapi.NewMessage(message.Chat.ID, text)
msg.ReplyMarkup = tgbotapi.ForceReply{
ForceReply: true,
Selective: true,
}
_, err := telegram.API.Send(msg)
return err
}

6
go.mod

@ -4,15 +4,21 @@ go 1.14
require ( require (
github.com/Flaque/filet v0.0.0-20190209224823-fc4d33cfcf93 github.com/Flaque/filet v0.0.0-20190209224823-fc4d33cfcf93
github.com/coreos/go-oidc v2.2.1+incompatible
github.com/gambol99/go-oidc v0.0.0-20180331113633-87948fe50989 // indirect
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/google/uuid v1.1.1 github.com/google/uuid v1.1.1
github.com/gorilla/sessions v1.2.0
github.com/julienschmidt/httprouter v1.2.0 github.com/julienschmidt/httprouter v1.2.0
github.com/magiconair/properties v1.8.1 github.com/magiconair/properties v1.8.1
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
github.com/prometheus/common v0.4.0 github.com/prometheus/common v0.4.0
github.com/rakyll/statik v0.1.7 github.com/rakyll/statik v0.1.7
github.com/spf13/afero v1.1.2 github.com/spf13/afero v1.1.2
github.com/spf13/viper v1.6.3 github.com/spf13/viper v1.6.3
github.com/technoweenie/multipartstreamer v1.0.1 // indirect github.com/technoweenie/multipartstreamer v1.0.1 // indirect
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be
golang.org/x/text v0.3.2 golang.org/x/text v0.3.2
gopkg.in/square/go-jose.v2 v2.5.1 // indirect
gopkg.in/yaml.v2 v2.2.8 gopkg.in/yaml.v2 v2.2.8
) )

14
go.sum

@ -14,6 +14,8 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
@ -23,6 +25,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gambol99/go-oidc v0.0.0-20180331113633-87948fe50989 h1:5zdjqvshRkw63AKrJtIEA8X2eNUM7dw9umpXp/y+jm0=
github.com/gambol99/go-oidc v0.0.0-20180331113633-87948fe50989/go.mod h1:zTNy+JHQPpCwXtyyATXdkUZPX/JjuX/pSFzwZmk9qaw=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@ -44,6 +48,10 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
@ -76,6 +84,8 @@ github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
@ -129,7 +139,9 @@ golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -160,6 +172,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w=
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

205
http.go

@ -1,67 +1,14 @@
package main package main
import ( import (
"html/template"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"path" "path"
"sort"
"strings" "strings"
"time"
_ "github.com/nmasse-itix/Telegram-Photo-Album-Bot/statik"
"github.com/rakyll/statik/fs" "github.com/rakyll/statik/fs"
"github.com/spf13/viper"
) )
func slurpFile(statikFS http.FileSystem, filename string) (string, error) {
fd, err := statikFS.Open(filename)
if err != nil {
return "", err
}
defer fd.Close()
content, err := ioutil.ReadAll(fd)
if err != nil {
return "", err
}
return string(content), nil
}
func getTemplate(statikFS http.FileSystem, filename string, name string) (*template.Template, error) {
tmpl := template.New(name)
content, err := slurpFile(statikFS, filename)
if err != nil {
return nil, err
}
customFunctions := template.FuncMap{
"video": func(files []string) string {
for _, file := range files {
if strings.HasSuffix(file, ".mp4") {
return file
}
}
return ""
},
"photo": func(files []string) string {
for _, file := range files {
if strings.HasSuffix(file, ".jpeg") {
return file
}
}
return ""
},
"short": func(t time.Time) string {
return t.Format("2006-01")
},
}
return tmpl.Funcs(customFunctions).Parse(content)
}
// ShiftPath splits off the first component of p, which will be cleaned of // ShiftPath splits off the first component of p, which will be cleaned of
// relative components before processing. head will never contain a slash and // relative components before processing. head will never contain a slash and
// tail will always be a rooted path without trailing slash. // tail will always be a rooted path without trailing slash.
@ -78,13 +25,7 @@ func ShiftPath(p string) (head, tail string) {
return p[1:i], p[i:] return p[1:i], p[i:]
} }
type WebInterface struct { func (bot *PhotoBot) ServeWebInterface(listenAddr string, frontend *SecurityFrontend) {
AlbumTemplate *template.Template
MediaTemplate *template.Template
IndexTemplate *template.Template
}
func (bot *PhotoBot) ServeWebInterface(listenAddr string) {
statikFS, err := fs.New() statikFS, err := fs.New()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -108,7 +49,10 @@ func (bot *PhotoBot) ServeWebInterface(listenAddr string) {
router := http.NewServeMux() router := http.NewServeMux()
router.Handle("/js/", http.FileServer(statikFS)) router.Handle("/js/", http.FileServer(statikFS))
router.Handle("/css/", http.FileServer(statikFS)) router.Handle("/css/", http.FileServer(statikFS))
router.Handle("/", bot)
// Put the Web Interface behind the security frontend
frontend.Protected = bot
router.Handle("/", frontend)
server := &http.Server{ server := &http.Server{
Addr: listenAddr, Addr: listenAddr,
@ -116,142 +60,3 @@ func (bot *PhotoBot) ServeWebInterface(listenAddr string) {
} }
log.Fatal(server.ListenAndServe()) log.Fatal(server.ListenAndServe())
} }
func (bot *PhotoBot) HandleFileNotFound(w http.ResponseWriter, r *http.Request) {
http.Error(w, "File not found", http.StatusNotFound)
}
func (bot *PhotoBot) HandleError(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func (bot *PhotoBot) HandleDisplayAlbum(w http.ResponseWriter, r *http.Request, albumName string) {
if albumName == "latest" {
albumName = ""
}
album, err := bot.MediaStore.GetAlbum(albumName, false)
if err != nil {
log.Printf("MediaStore.GetAlbum: %s", err)
bot.HandleError(w, r)
return
}
err = bot.WebInterface.AlbumTemplate.Execute(w, album)
if err != nil {
log.Printf("Template.Execute: %s", err)
bot.HandleError(w, r)
return
}
}
func (bot *PhotoBot) HandleDisplayIndex(w http.ResponseWriter, r *http.Request) {
albums, err := bot.MediaStore.ListAlbums()
if err != nil {
log.Printf("MediaStore.ListAlbums: %s", err)
bot.HandleError(w, r)
return
}
sort.Sort(sort.Reverse(albums))
err = bot.WebInterface.IndexTemplate.Execute(w, struct {
Title string
Albums []Album
}{
viper.GetString("SiteName"),
albums,
})
if err != nil {
log.Printf("Template.Execute: %s", err)
bot.HandleError(w, r)
return
}
}
func (bot *PhotoBot) HandleDisplayMedia(w http.ResponseWriter, r *http.Request, albumName string, mediaId string) {
if albumName == "latest" {
albumName = ""
}
media, err := bot.MediaStore.GetMedia(albumName, mediaId)
if err != nil {
log.Printf("MediaStore.GetMedia: %s", err)
bot.HandleError(w, r)
return
}
if media == nil {
bot.HandleFileNotFound(w, r)
return
}
err = bot.WebInterface.MediaTemplate.Execute(w, media)
if err != nil {
log.Printf("Template.Execute: %s", err)
bot.HandleError(w, r)
return
}
}
func (bot *PhotoBot) HandleGetMedia(w http.ResponseWriter, r *http.Request, albumName string, mediaFilename string) {
if albumName == "latest" {
albumName = ""
}
fd, modtime, err := bot.MediaStore.OpenFile(albumName, mediaFilename)
if err != nil {
log.Printf("MediaStore.OpenFile: %s", err)
bot.HandleError(w, r)
return
}
defer fd.Close()
http.ServeContent(w, r, mediaFilename, modtime, fd)
}
func (bot *PhotoBot) ServeHTTP(w http.ResponseWriter, r *http.Request) {
originalPath := r.URL.Path
var resource string
resource, r.URL.Path = ShiftPath(r.URL.Path)
switch r.Method {
case "GET":
if resource == "album" {
var albumName, kind, media string
albumName, r.URL.Path = ShiftPath(r.URL.Path)
kind, r.URL.Path = ShiftPath(r.URL.Path)
media, r.URL.Path = ShiftPath(r.URL.Path)
if albumName != "" {
if kind == "" && media == "" {
if !strings.HasSuffix(originalPath, "/") {
http.Redirect(w, r, originalPath+"/", http.StatusMovedPermanently)
return
}
bot.HandleDisplayAlbum(w, r, albumName)
return
} else if kind == "raw" && media != "" {
bot.HandleGetMedia(w, r, albumName, media)
return
} else if kind == "media" && media != "" {
bot.HandleDisplayMedia(w, r, albumName, media)
return
}
} else {
if !strings.HasSuffix(originalPath, "/") {
http.Redirect(w, r, originalPath+"/", http.StatusMovedPermanently)
return
}
bot.HandleDisplayIndex(w, r)
return
}
} else if resource == "" {
http.Redirect(w, r, "/album/", http.StatusMovedPermanently)
return
}
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bot.HandleFileNotFound(w, r)
}

199
main.go

@ -3,6 +3,8 @@
package main package main
import ( import (
"crypto"
"encoding/base64"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -14,22 +16,47 @@ import (
func initConfig() { func initConfig() {
// how many seconds to wait between retries, upon Telegram API errors // how many seconds to wait between retries, upon Telegram API errors
viper.SetDefault("RetryDelay", 60) viper.SetDefault("Telegram.RetryDelay", 60)
// max duration between two telegram updates // max duration between two telegram updates
viper.SetDefault("TelegramNewUpdateTimeout", 60) viper.SetDefault("Telegram.NewUpdateTimeout", 60)
viper.SetDefault("SiteName", "My photo album")
// Telegram messages
// Default messages viper.SetDefault("Telegram.Messages.Forbidden", "Access Denied")
viper.SetDefault("MsgForbidden", "Access Denied") viper.SetDefault("Telegram.Messages.Help", `Hello, I'm the photo bot!
viper.SetDefault("MsgHelp", "Hello")
viper.SetDefault("MsgMissingAlbumName", "The album name is missing") You can send me your photos and videos.
viper.SetDefault("MsgServerError", "Server Error")
viper.SetDefault("MsgAlbumCreated", "Album created") To start an album, use "/newAlbum album name".
viper.SetDefault("MsgDoNotUnderstand", "Unknown command") To get the current album name, use "/info".
viper.SetDefault("MsgInfoNoAlbum", "There is no album started, yet.") To share an album, use "/share album".
viper.SetDefault("MsgNoUsername", "Sorry, you need to set your username") To share all albums, use "/share".
viper.SetDefault("MsgThankYouMedia", "Got it, thanks!") If you are lost, you can get this message again with "/help".
viper.SetDefault("MsgSendMeSomething", "OK. Send me something.")
Have a nice day!`)
viper.SetDefault("Telegram.Messages.MissingAlbumName", "The album name is missing")
viper.SetDefault("Telegram.Messages.ServerError", "Server Internal Error")
viper.SetDefault("Telegram.Messages.AlbumCreated", "Album created")
viper.SetDefault("Telegram.Messages.DoNotUnderstand", "Sorry, I did not understand your request.")
viper.SetDefault("Telegram.Messages.Info", "Current album is named %s. Please send me your photos and videos!")
viper.SetDefault("Telegram.Messages.InfoNoAlbum", "There is no album started, yet.")
viper.SetDefault("Telegram.Messages.NoUsername", "You need to set your Telegram username first!")
viper.SetDefault("Telegram.Messages.ThankYouMedia", "Got it, thanks!")
viper.SetDefault("Telegram.Messages.SharedAlbum", "Album %s shared:")
viper.SetDefault("Telegram.Messages.SharedGlobal", "Link to all albums:")
// Telegram Commands
viper.SetDefault("Telegram.Commands.Help", "help")
viper.SetDefault("Telegram.Commands.Info", "info")
viper.SetDefault("Telegram.Commands.NewAlbum", "newAlbum")
viper.SetDefault("Telegram.Commands.Share", "share")
// Web Interface
viper.SetDefault("WebInterface.SiteName", "My photo album")
viper.SetDefault("WebInterface.Listen", "127.0.0.1:8080")
viper.SetDefault("WebInterface.Sessions.SecureCookie", true)
viper.SetDefault("WebInterface.Sessions.CookieMaxAge", 86400*7)
viper.SetDefault("Telegram.TokenGenerator.GlobalValidity", 1)
viper.SetDefault("Telegram.TokenGenerator.PerAlbumValidity", 7)
viper.SetConfigName("photo-bot") // name of config file (without extension) viper.SetConfigName("photo-bot") // name of config file (without extension)
viper.AddConfigPath("/etc/photo-bot/") viper.AddConfigPath("/etc/photo-bot/")
@ -62,30 +89,86 @@ func validateConfig() {
log.Fatalf("Cannot find target directory: %s: %s", targetDir, err) log.Fatalf("Cannot find target directory: %s: %s", targetDir, err)
} }
retryDelay := viper.GetInt("RetryDelay") retryDelay := viper.GetInt("Telegram.RetryDelay")
if retryDelay <= 0 { if retryDelay <= 0 {
log.Fatal("The RetryDelay cannot be zero or negative!") log.Fatal("The RetryDelay cannot be zero or negative!")
} }
timeout := viper.GetInt("TelegramNewUpdateTimeout") timeout := viper.GetInt("Telegram.NewUpdateTimeout")
if timeout <= 0 { if timeout <= 0 {
log.Fatal("The TelegramNewUpdateTimeout cannot be zero or negative!") log.Fatal("The TelegramNewUpdateTimeout cannot be zero or negative!")
} }
token := viper.GetString("TelegramToken") token := viper.GetString("Telegram.Token")
if token == "" { if token == "" {
log.Fatal("No Telegram Bot Token provided!") log.Fatal("No Telegram Bot Token provided!")
} }
listenAddr := viper.GetString("HttpListen") authorizedUsersList := viper.GetStringSlice("Telegram.AuthorizedUsers")
if listenAddr == "" {
log.Fatal("No listen address provided!")
}
authorizedUsersList := viper.GetStringSlice("AuthorizedUsers")
if len(authorizedUsersList) == 0 { if len(authorizedUsersList) == 0 {
log.Fatal("A list of AuthorizedUsers must be given!") log.Fatal("A list of AuthorizedUsers must be given!")
} }
if viper.GetString("WebInterface.OIDC.DiscoveryUrl") == "" {
log.Fatal("No OpenID Connect Discovery URL provided!")
}
if viper.GetString("WebInterface.OIDC.ClientID") == "" {
log.Fatal("No OpenID Connect Client ID provided!")
}
if viper.GetString("WebInterface.OIDC.ClientSecret") == "" {
log.Fatal("No OpenID Connect Client Secret provided!")
}
if viper.GetString("WebInterface.OIDC.RedirectURL") == "" {
log.Fatal("No OpenID Connect Redirect URL provided!")
}
if viper.GetString("WebInterface.OIDC.ClientSecret") == "" {
log.Fatal("No OpenID Connect Client Secret provided!")
}
if viper.GetString("WebInterface.Sessions.AuthenticationKey") == "" {
log.Fatal("No Cookie Authentication Key provided!")
}
if viper.GetString("WebInterface.Sessions.EncryptionKey") == "" {
log.Fatal("No Cookie Encryption Key provided!")
}
if viper.GetString("WebInterface.PublicURL") == "" {
log.Fatal("No Public URL provided!")
}
if viper.GetString("Telegram.TokenGenerator.AuthenticationKey") == "" {
log.Fatal("No Token Generator Authentication Key provided!")
}
}
func getCommandsFromConfig() TelegramCommands {
return TelegramCommands{
Help: viper.GetString("Telegram.Commands.Help"),
NewAlbum: viper.GetString("Telegram.Commands.NewAlbum"),
Info: viper.GetString("Telegram.Commands.Info"),
Share: viper.GetString("Telegram.Commands.Share"),
}
}
func getMessagesFromConfig() TelegramMessages {
return TelegramMessages{
Forbidden: viper.GetString("Telegram.Messages.Forbidden"),
Help: viper.GetString("Telegram.Messages.Help"),
MissingAlbumName: viper.GetString("Telegram.Messages.MissingAlbumName"),
ServerError: viper.GetString("Telegram.Messages.ServerError"),
AlbumCreated: viper.GetString("Telegram.Messages.AlbumCreated"),
DoNotUnderstand: viper.GetString("Telegram.Messages.DoNotUnderstand"),
Info: viper.GetString("Telegram.Messages.Info"),
InfoNoAlbum: viper.GetString("Telegram.Messages.InfoNoAlbum"),
NoUsername: viper.GetString("Telegram.Messages.NoUsername"),
SharedAlbum: viper.GetString("Telegram.Messages.SharedAlbum"),
SharedGlobal: viper.GetString("Telegram.Messages.SharedGlobal"),
}
} }
func main() { func main() {
@ -94,11 +177,15 @@ func main() {
// Create the Bot // Create the Bot
photoBot := InitBot(viper.GetString("TargetDir")) photoBot := InitBot(viper.GetString("TargetDir"))
photoBot.Telegram.RetryDelay = time.Duration(viper.GetInt("RetryDelay")) * time.Second photoBot.Telegram.RetryDelay = time.Duration(viper.GetInt("Telegram.RetryDelay")) * time.Second
photoBot.Telegram.NewUpdateTimeout = viper.GetInt("TelegramNewUpdateTimeout") photoBot.Telegram.NewUpdateTimeout = viper.GetInt("Telegram.NewUpdateTimeout")
photoBot.Telegram.Commands = getCommandsFromConfig()
photoBot.Telegram.Messages = getMessagesFromConfig()
photoBot.WebInterface.SiteName = viper.GetString("WebInterface.SiteName")
photoBot.Telegram.WebPublicURL = viper.GetString("WebInterface.PublicURL")
// Fill the authorized users // Fill the authorized users
for _, item := range viper.GetStringSlice("AuthorizedUsers") { for _, item := range viper.GetStringSlice("Telegram.AuthorizedUsers") {
photoBot.Telegram.AuthorizedUsers[item] = true photoBot.Telegram.AuthorizedUsers[item] = true
} }
@ -107,7 +194,7 @@ func main() {
fullPath := filepath.Join(targetDir, dir) fullPath := filepath.Join(targetDir, dir)
var err error = os.MkdirAll(fullPath, 0777) var err error = os.MkdirAll(fullPath, 0777)
if err != nil { if err != nil {
log.Fatalf("os.MkdirAll: %s: %s\n", fullPath, err) panic(fmt.Sprintf("os.MkdirAll: %s: %s\n", fullPath, err))
} }
} }
@ -126,10 +213,60 @@ func main() {
photoBot.MediaStore = mediaStore photoBot.MediaStore = mediaStore
// Start the bot // Start the bot
photoBot.StartBot(viper.GetString("TelegramToken")) photoBot.StartBot(viper.GetString("Telegram.Token"))
photoBot.Telegram.API.Debug = viper.GetBool("TelegramDebug") photoBot.Telegram.API.Debug = viper.GetBool("Telegram.Debug")
// Token Generator
tokenAuthenticationKey, err := base64.StdEncoding.DecodeString(viper.GetString("Telegram.TokenGenerator.AuthenticationKey"))
if err != nil {
panic(err)
}
if len(tokenAuthenticationKey) < 32 {
panic("The given token generator authentication key is too short!")
}
tokenGenerator, err := NewTokenGenerator(tokenAuthenticationKey, crypto.SHA256)
if err != nil {
panic(err)
}
photoBot.Telegram.TokenGenerator = tokenGenerator
// Setup the web interface
var oidc OpenIdSettings = OpenIdSettings{
ClientID: viper.GetString("WebInterface.OIDC.ClientID"),
ClientSecret: viper.GetString("WebInterface.OIDC.ClientSecret"),
DiscoveryUrl: viper.GetString("WebInterface.OIDC.DiscoveryUrl"),
RedirectURL: viper.GetString("WebInterface.OIDC.RedirectURL"),
GSuiteDomain: viper.GetString("WebInterface.OIDC.GSuiteDomain"),
Scopes: viper.GetStringSlice("WebInterface.OIDC.Scopes"),
}
authenticationKey, err := base64.StdEncoding.DecodeString(viper.GetString("WebInterface.Sessions.AuthenticationKey"))
if err != nil {
panic(err)
}
if len(authenticationKey) < 32 {
panic("The given session authentication key is too short!")
}
encryptionKey, err := base64.StdEncoding.DecodeString(viper.GetString("WebInterface.Sessions.EncryptionKey"))
if err != nil {
panic(err)
}
if len(encryptionKey) < 32 {
panic("The given session encryption key is too short!")
}
var sessions SessionSettings = SessionSettings{
AuthenticationKey: authenticationKey,
EncryptionKey: encryptionKey,
CookieMaxAge: viper.GetInt("WebInterface.Sessions.CookieMaxAge"),
SecureCookie: viper.GetBool("WebInterface.Sessions.SecureCookie"),
}
securityFrontend, err := NewSecurityFrontend(oidc, sessions, tokenGenerator)
if err != nil {
panic(err)
}
securityFrontend.GlobalTokenValidity = viper.GetInt("Telegram.TokenGenerator.GlobalValidity")
securityFrontend.PerAlbumTokenValidity = viper.GetInt("Telegram.TokenGenerator.PerAlbumValidity")
initLogFile() initLogFile()
go photoBot.Process() go photoBot.Process()
photoBot.ServeWebInterface(viper.GetString("HttpListen")) photoBot.ServeWebInterface(viper.GetString("WebInterface.Listen"), securityFrontend)
} }

32
secret.go

@ -0,0 +1,32 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
)
type Secret []byte
func (r Secret) String() string {
return hex.EncodeToString(r)
}
func (r Secret) Hashed() string {
hash := sha256.Sum256(r)
return hex.EncodeToString(hash[:])
}
func newRandomSecret(size int) (Secret, error) {
var r Secret = make([]byte, size)
_, err := rand.Read(r)
if err != nil {
return Secret{}, err
}
return r, nil
}
func secretFromHex(encoded string) (Secret, error) {
return hex.DecodeString(encoded)
}

35
secret_test.go

@ -0,0 +1,35 @@
package main
import (
"testing"
"github.com/magiconair/properties/assert"
)
func TestRandomSecretLength(t *testing.T) {
secret, err := newRandomSecret(32)
if err != nil {
t.Errorf("newRandomSecret(): %s", err)
}
assert.Equal(t, len(secret), 32, "random secret is 32 bytes long")
}
func TestSecretFromHex(t *testing.T) {
secretHex := "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff"
secret, err := secretFromHex(secretHex)
if err != nil {
t.Errorf("secretFromHex(): %s", err)
}
assert.Equal(t, len(secret), 32, "secret value is 32 bytes long")
assert.Equal(t, secret.String(), secretHex, "Secret.String prints the secret value as hex")
}
func TestSecretHashed(t *testing.T) {
secretHex := "2e6cf592c0c41e57643b915dd719e0ffb681fd5183c3498e8a9802730a03c3e6"
hashHex := "e4cb5359be709b6e35c48cfcfa2b661f576300000126dae2dd99d8949267c1c3"
secret, err := secretFromHex(secretHex)
if err != nil {
t.Errorf("secretFromHex(): %s", err)
}
assert.Equal(t, secret.Hashed(), hashHex, "Secret.Hashed prints the hashed value as hex")
}

308
security.go

@ -0,0 +1,308 @@
package main
import (
"context"
"encoding/gob"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/coreos/go-oidc"
"github.com/gorilla/sessions"
"golang.org/x/oauth2"
)
type SecurityFrontend struct {
OpenId OpenIdSettings
Protected http.Handler
TokenGenerator *TokenGenerator
GlobalTokenValidity int
PerAlbumTokenValidity int
store *sessions.CookieStore
oAuth2Config *oauth2.Config
oidcVerifier *oidc.IDTokenVerifier
}
type SessionSettings struct {
AuthenticationKey []byte
EncryptionKey []byte
CookieMaxAge int
SecureCookie bool
}
type OpenIdSettings struct {
DiscoveryUrl string
ClientID string
ClientSecret string
RedirectURL string
GSuiteDomain string
Scopes []string
}
func init() {
gob.Register(&WebUser{})
}
func NewSecurityFrontend(openidSettings OpenIdSettings, sessionSettings SessionSettings, tokenGenerator *TokenGenerator) (*SecurityFrontend, error) {
var securityFrontend SecurityFrontend
provider, err := oidc.NewProvider(context.TODO(), openidSettings.DiscoveryUrl)
if err != nil {
return nil, err
}
securityFrontend.oAuth2Config = &oauth2.Config{
ClientID: openidSettings.ClientID,
ClientSecret: openidSettings.ClientSecret,
RedirectURL: openidSettings.RedirectURL,
// Discovery returns the OAuth2 endpoints.
Endpoint: provider.Endpoint(),
// "openid" is a required scope for OpenID Connect flows.
Scopes: append(openidSettings.Scopes, oidc.ScopeOpenID),
}
securityFrontend.oidcVerifier = provider.Verifier(&oidc.Config{ClientID: openidSettings.ClientID})
securityFrontend.store = sessions.NewCookieStore(sessionSettings.AuthenticationKey, sessionSettings.EncryptionKey)
securityFrontend.store.Options = &sessions.Options{
Path: "/",
MaxAge: sessionSettings.CookieMaxAge,
HttpOnly: true,
Secure: sessionSettings.SecureCookie,
}
securityFrontend.OpenId = openidSettings
securityFrontend.TokenGenerator = tokenGenerator
return &securityFrontend, nil
}
func (securityFrontend *SecurityFrontend) ServeHTTP(w http.ResponseWriter, r *http.Request) {
originalPath := r.URL.Path
if r.URL.Path == "/oauth/callback" {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
securityFrontend.handleOidcCallback(w, r)
return
}
head, tail := ShiftPath(r.URL.Path)
var user *WebUser
if head == "s" {
var ok bool
r.URL.Path = tail
user, ok = securityFrontend.handleTelegramTokenAuthentication(w, r)
if !ok {
return
}
} else if head == "album" {
var ok bool
user, ok = securityFrontend.handleOidcAuthentication(w, r)
if !ok {
return
}
} else {
user = &WebUser{}
}
log.Printf("[%s] %s %s", user, r.Method, r.URL.Path)
// Respect the user's choice about trailing slash
if strings.HasSuffix(originalPath, "/") && !strings.HasSuffix(r.URL.Path, "/") {
r.URL.Path = r.URL.Path + "/"
}
securityFrontend.Protected.ServeHTTP(w, r)
}
func (securityFrontend *SecurityFrontend) handleOidcRedirect(w http.ResponseWriter, r *http.Request, session *sessions.Session, forcedTargetPath string) {
nonce, err := newRandomSecret(32)
if err != nil {
log.Printf("rand.Read: %s", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
state, err := newRandomSecret(32)
if err != nil {
log.Printf("rand.Read: %s", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
session.AddFlash(nonce.String())
session.AddFlash(state.String())
if forcedTargetPath != "" {
session.AddFlash(forcedTargetPath)
} else {
session.AddFlash(r.URL.Path)
}
err = session.Save(r, w)
if err != nil {
log.Printf("Session.Save: %s", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, securityFrontend.oAuth2Config.AuthCodeURL(state.Hashed(), oidc.Nonce(nonce.Hashed())), http.StatusFound)
}
func (securityFrontend *SecurityFrontend) handleOidcCallback(w http.ResponseWriter, r *http.Request) {
session, err := securityFrontend.store.Get(r, "oidc")
if err != nil {
log.Printf("session.Store.Get: %s", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Retrieve the nonce and state from the session flashes
nonceAndState := session.Flashes()
if len(nonceAndState) < 3 { // there may be more than two if the user performs multiple attempts
log.Printf("session.Flashes: no (nonce,state,redirect_path) found in current session (len = %d)", len(nonceAndState))
securityFrontend.handleOidcRedirect(w, r, session, "/")
return
}
nonce, err := secretFromHex(nonceAndState[len(nonceAndState)-3].(string))
if err != nil {
log.Printf("hex.DecodeString: %s", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
state, err := secretFromHex(nonceAndState[len(nonceAndState)-2].(string))
if err != nil {
log.Printf("hex.DecodeString: %s", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
redirect_path := nonceAndState[len(nonceAndState)-1].(string)
if redirect_path == "" {
redirect_path = "/"
}
if r.URL.Query().Get("state") != state.Hashed() {
log.Println("OIDC callback: state do not match")
http.Error(w, "state does not match", http.StatusBadRequest)
return
}
oauth2Token, err := securityFrontend.oAuth2Config.Exchange(context.TODO(), r.URL.Query().Get("code"))
if err != nil {
log.Printf("oauth2.Config.Exchange: %s", err)
http.Error(w, "Invalid Authorization Code", http.StatusBadRequest)
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
log.Println("Token.Extra: No id_token field in oauth2 token")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
user, err := securityFrontend.validateIdToken(rawIDToken, nonce.Hashed())
if err != nil {
log.Printf("validateIdToken: %s", err)
//log.Printf("invalid id_token: %s", rawIDToken)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
log.Printf("HTTP: user %s logged in", user.Username)
session.Values["user"] = &user
err = session.Save(r, w)
if err != nil {
log.Printf("Session.Save: %s", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, redirect_path, http.StatusFound)
}
func (securityFrontend *SecurityFrontend) validateIdToken(rawIDToken string, nonce string) (WebUser, error) {
idToken, err := securityFrontend.oidcVerifier.Verify(context.TODO(), rawIDToken)
if err != nil {
return WebUser{}, fmt.Errorf("IDTokenVerifier.Verify: %s", err)
}
if idToken.Nonce != nonce {
return WebUser{}, fmt.Errorf("nonces do not match in id_token")
}
var claims struct {
Email string `json:"email"`
GSuiteDomain string `json:"hd"`
}
err = idToken.Claims(&claims)
if err != nil {
return WebUser{}, fmt.Errorf("IdToken.Claims: %s", err)
}
if securityFrontend.OpenId.GSuiteDomain != "" && securityFrontend.OpenId.GSuiteDomain != claims.GSuiteDomain {
return WebUser{}, fmt.Errorf("GSuite domain '%s' is not allowed", claims.GSuiteDomain)
}
return WebUser{Username: claims.Email, Type: TypeOidcUser}, nil
}
func (securityFrontend *SecurityFrontend) handleOidcAuthentication(w http.ResponseWriter, r *http.Request) (*WebUser, bool) {
session, err := securityFrontend.store.Get(r, "oidc")
if err != nil {
log.Printf("session.Store.Get: %s", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return &WebUser{}, false
}
u := session.Values["user"]
if u == nil {
securityFrontend.handleOidcRedirect(w, r, session, "")
return &WebUser{}, false
}
user, ok := u.(*WebUser)
if !ok {
log.Println("Cannot cast session item 'user' as WebUser")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return &WebUser{}, false
}
return user, true
}
func (securityFrontend *SecurityFrontend) handleTelegramTokenAuthentication(w http.ResponseWriter, r *http.Request) (*WebUser, bool) {
var username, token string
username, r.URL.Path = ShiftPath(r.URL.Path)
token, r.URL.Path = ShiftPath(r.URL.Path)
var tail string
_, tail = ShiftPath(r.URL.Path)
album, _ := ShiftPath(tail)
data := TokenData{
Username: username,
Timestamp: time.Now(),
Entitlement: album,
}
ok, err := securityFrontend.TokenGenerator.ValidateToken(data, token, securityFrontend.PerAlbumTokenValidity)
if err != nil {
http.Error(w, "Invalid Token", http.StatusBadRequest)
return nil, false
}
if !ok {
data.Entitlement = ""
ok, err := securityFrontend.TokenGenerator.ValidateToken(data, token, securityFrontend.GlobalTokenValidity)
if !ok || err != nil {
http.Error(w, "Invalid Token", http.StatusBadRequest)
return nil, false
}
}
return &WebUser{Username: username, Type: TypeTelegramUser}, true
}

91
token.go

@ -0,0 +1,91 @@
package main
import (
"bytes"
"crypto"
"crypto/hmac"
"encoding/base64"
"encoding/binary"
"fmt"
"time"
)
type TokenGenerator struct {
AuthenticationKey []byte
Algorithm crypto.Hash
}
type TokenData struct {
Timestamp time.Time
Username string
Entitlement string
}
func NewTokenGenerator(authenticationKey []byte, algorithm crypto.Hash) (*TokenGenerator, error) {
if !algorithm.Available() {
return nil, fmt.Errorf("Hash algorithm %d is not available", algorithm)
}
return &TokenGenerator{AuthenticationKey: authenticationKey, Algorithm: algorithm}, nil
}
func (g *TokenGenerator) NewToken(data TokenData) string {
// Fill a buffer with the token data
buffer := getBufferFor(data)
// Pass the token data to the hash function
hasher := hmac.New(g.Algorithm.New, g.AuthenticationKey)
hasher.Write(buffer)
hash := hasher.Sum(nil)
//fmt.Println(hex.EncodeToString(hash))
return base64.StdEncoding.EncodeToString(hash)
}
func getBufferFor(data TokenData) []byte {
// Compute the number days since year 2000
// Note: there is a one-off error if the token span across the end of a leap year
var daysSinceY2K uint32 = uint32((data.Timestamp.Year()-2000)*365 + data.Timestamp.YearDay())
//fmt.Printf("Days since Y2K = %d\n", daysSinceY2K)
// Pack the token data in a buffer
// - number of days since epoch
// - username that generated the token
// - entitlement for the resulting token
usernameBytes := []byte(data.Username)
entitlementBytes := []byte(data.Entitlement)
bufferLen := len(usernameBytes) + len(entitlementBytes) + 5 // 4 bytes for daysSinceEpoch + one '\0' separator
var buffer []byte = make([]byte, bufferLen)
binary.LittleEndian.PutUint32(buffer, daysSinceY2K)
start, stop := 4, 4+len(usernameBytes)
copy(buffer[start:stop], usernameBytes)
start, stop = stop+1, stop+1+len(entitlementBytes)
copy(buffer[start:stop], entitlementBytes)
//fmt.Println(hex.EncodeToString(buffer))
return buffer
}
func (g *TokenGenerator) ValidateToken(data TokenData, token string, validity int) (bool, error) {
rawToken, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return false, err
}
hasher := hmac.New(g.Algorithm.New, g.AuthenticationKey)
for days := 0; days < validity; days = days + 1 {
attempt := data
attempt.Timestamp = data.Timestamp.Add(time.Hour * -24 * time.Duration(days))
buffer := getBufferFor(attempt)
hasher.Reset()
hasher.Write(buffer)
hash := hasher.Sum(nil)
if bytes.Compare(hash, rawToken) == 0 {
return true, nil
}
}
return false, nil
}

49
token_test.go

@ -0,0 +1,49 @@
package main
import (
"crypto"
"encoding/hex"
"testing"
"time"
"github.com/magiconair/properties/assert"
)
func TestTokenGenerator(t *testing.T) {
// export KEY="$(openssl rand -hex 32)"
secretHex := "6b68b32607bae2c3d5e140efd8f4d5b6518fced3081fc6b28478b903ceef9aa3"
secret, err := hex.DecodeString(secretHex)
if err != nil {
t.Errorf("secretFromHex(): %s", err)
}
g, err := NewTokenGenerator(secret, crypto.SHA256)
if err != nil {
t.Errorf("NewTokenGenerator(): %s", err)
}
now := time.Unix(1588703522, 0) // date +%s
token := g.NewToken(TokenData{now, "nmasse", "read"})
// echo "000000: 021d 0000 6e6d 6173 7365 0072 6561 64" |xxd -r | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary |openssl base64
expectedToken := "McChidYyEfEPkotTq08EW+eYHjd2QX+wlUzgGjOhWlY="
assert.Equal(t, token, expectedToken, "expected a valid token")
sixDaysLater := time.Unix(1589221922, 0)
ok, err := g.ValidateToken(TokenData{sixDaysLater, "nmasse", "read"}, expectedToken, 7)
if err != nil {
t.Errorf("ValidateToken(sixDaysLater): %s", err)
}
if !ok {
t.Errorf("ValidateToken(sixDaysLater): token is not valid")
}
sevenDaysLater := time.Unix(1589308322, 0)
ok, err = g.ValidateToken(TokenData{sevenDaysLater, "nmasse", "read"}, expectedToken, 7)
if err != nil {
t.Errorf("ValidateToken(sevenDaysLater): %s", err)
}
if ok {
t.Errorf("ValidateToken(sevenDaysLater): token is valid")
}
}

38
user.go

@ -0,0 +1,38 @@
package main
import "fmt"
type UserType int
const (
TypeAnonymous UserType = 0
TypeTelegramUser UserType = 1
TypeOidcUser UserType = 2
)
func (t UserType) String() string {
names := [...]string{
"Anonymous",
"Telegram",
"OIDC",
}
if t < TypeAnonymous || t > TypeOidcUser {
return "Unknown"
}
return names[t]
}
type WebUser struct {
Username string
Type UserType
}
func (u WebUser) String() string {
if u.Type == TypeAnonymous {
return "Anonymous"
}
return fmt.Sprintf("%s:%s", u.Type, u.Username)
}

205
web.go

@ -0,0 +1,205 @@
package main
import (
"html/template"
"io/ioutil"
"log"
"net/http"
"sort"
"strings"
"time"
_ "github.com/nmasse-itix/Telegram-Photo-Album-Bot/statik"
)
type WebInterface struct {
AlbumTemplate *template.Template
MediaTemplate *template.Template
IndexTemplate *template.Template
SiteName string
}
func slurpFile(statikFS http.FileSystem, filename string) (string, error) {
fd, err := statikFS.Open(filename)
if err != nil {
return "", err
}
defer fd.Close()
content, err := ioutil.ReadAll(fd)
if err != nil {
return "", err
}
return string(content), nil
}
func getTemplate(statikFS http.FileSystem, filename string, name string) (*template.Template, error) {
tmpl := template.New(name)
content, err := slurpFile(statikFS, filename)
if err != nil {
return nil, err
}
customFunctions := template.FuncMap{
"video": func(files []string) string {
for _, file := range files {
if strings.HasSuffix(file, ".mp4") {
return file
}
}
return ""
},
"photo": func(files []string) string {
for _, file := range files {
if strings.HasSuffix(file, ".jpeg") {
return file
}
}
return ""
},
"short": func(t time.Time) string {
return t.Format("2006-01")
},
}
return tmpl.Funcs(customFunctions).Parse(content)
}
func (bot *PhotoBot) HandleFileNotFound(w http.ResponseWriter, r *http.Request) {
http.Error(w, "File not found", http.StatusNotFound)
}
func (bot *PhotoBot) HandleError(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func (bot *PhotoBot) HandleDisplayAlbum(w http.ResponseWriter, r *http.Request, albumName string) {
if albumName == "latest" {
albumName = ""
}
album, err := bot.MediaStore.GetAlbum(albumName, false)
if err != nil {
log.Printf("MediaStore.GetAlbum: %s", err)
bot.HandleError(w, r)
return
}
err = bot.WebInterface.AlbumTemplate.Execute(w, album)
if err != nil {
log.Printf("Template.Execute: %s", err)
bot.HandleError(w, r)
return
}
}
func (bot *PhotoBot) HandleDisplayIndex(w http.ResponseWriter, r *http.Request) {
albums, err := bot.MediaStore.ListAlbums()
if err != nil {
log.Printf("MediaStore.ListAlbums: %s", err)
bot.HandleError(w, r)
return
}
sort.Sort(sort.Reverse(albums))
err = bot.WebInterface.IndexTemplate.Execute(w, struct {
Title string
Albums []Album
}{
bot.WebInterface.SiteName,
albums,
})
if err != nil {
log.Printf("Template.Execute: %s", err)
bot.HandleError(w, r)
return
}
}
func (bot *PhotoBot) HandleDisplayMedia(w http.ResponseWriter, r *http.Request, albumName string, mediaId string) {
if albumName == "latest" {
albumName = ""
}
media, err := bot.MediaStore.GetMedia(albumName, mediaId)
if err != nil {
log.Printf("MediaStore.GetMedia: %s", err)
bot.HandleError(w, r)
return
}
if media == nil {
bot.HandleFileNotFound(w, r)
return
}
err = bot.WebInterface.MediaTemplate.Execute(w, media)
if err != nil {
log.Printf("Template.Execute: %s", err)
bot.HandleError(w, r)
return
}
}
func (bot *PhotoBot) HandleGetMedia(w http.ResponseWriter, r *http.Request, albumName string, mediaFilename string) {
if albumName == "latest" {
albumName = ""
}
fd, modtime, err := bot.MediaStore.OpenFile(albumName, mediaFilename)
if err != nil {
log.Printf("MediaStore.OpenFile: %s", err)
bot.HandleError(w, r)
return
}
defer fd.Close()
http.ServeContent(w, r, mediaFilename, modtime, fd)
}
func (bot *PhotoBot) ServeHTTP(w http.ResponseWriter, r *http.Request) {
originalPath := r.URL.Path
var resource string
resource, r.URL.Path = ShiftPath(r.URL.Path)
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if resource == "album" {
var albumName, kind, media string
albumName, r.URL.Path = ShiftPath(r.URL.Path)
kind, r.URL.Path = ShiftPath(r.URL.Path)
media, r.URL.Path = ShiftPath(r.URL.Path)
if albumName != "" {
if kind == "" && media == "" {
if !strings.HasSuffix(originalPath, "/") {
http.Redirect(w, r, originalPath+"/", http.StatusMovedPermanently)
return
}
bot.HandleDisplayAlbum(w, r, albumName)
return
} else if kind == "raw" && media != "" {
bot.HandleGetMedia(w, r, albumName, media)
return
} else if kind == "media" && media != "" {
bot.HandleDisplayMedia(w, r, albumName, media)
return
}
} else {
if !strings.HasSuffix(originalPath, "/") {
http.Redirect(w, r, originalPath+"/", http.StatusMovedPermanently)
return
}
bot.HandleDisplayIndex(w, r)
return
}
} else if resource == "" {
http.Redirect(w, r, "/album/", http.StatusMovedPermanently)
return
}
bot.HandleFileNotFound(w, r)
}
Loading…
Cancel
Save