diff --git a/bot.go b/bot.go index efe76f7..a511d81 100644 --- a/bot.go +++ b/bot.go @@ -5,10 +5,10 @@ import ( "io" "log" "net/http" + "net/url" "time" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" - "github.com/spf13/viper" ) type PhotoBot struct { @@ -18,11 +18,37 @@ type PhotoBot struct { } type TelegramBackend struct { + TokenGenerator *TokenGenerator + WebPublicURL string ChatDB *ChatDB AuthorizedUsers map[string]bool RetryDelay time.Duration NewUpdateTimeout int 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 { @@ -69,12 +95,12 @@ func (bot *PhotoBot) ProcessUpdate(update tgbotapi.Update) { username := update.Message.From.UserName if username == "" { - bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgNoUsername")) + bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.NoUsername) return } if !bot.Telegram.AuthorizedUsers[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 } @@ -87,41 +113,41 @@ func (bot *PhotoBot) ProcessUpdate(update tgbotapi.Update) { if update.Message.IsCommand() { log.Printf("[%s] command: %s", username, text) switch update.Message.Command() { - case "start", "aide", "help": + case "start", bot.Telegram.Commands.Help: bot.handleHelpCommand(update.Message) - case "nouvelAlbum": + case bot.Telegram.Commands.Share: + bot.handleShareCommand(update.Message) + case bot.Telegram.Commands.NewAlbum: bot.handleNewAlbumCommand(update.Message) - case "info": + case bot.Telegram.Commands.Info: bot.handleInfoCommand(update.Message) - case "pourLouise": - bot.Telegram.replyWithForcedReply(update.Message, viper.GetString("MsgSendMeSomething")) default: - bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgDoNotUnderstand")) + bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.DoNotUnderstand) } } else { - bot.Telegram.replyToCommandWithMessage(update.Message, viper.GetString("MsgDoNotUnderstand")) + bot.Telegram.replyToCommandWithMessage(update.Message, bot.Telegram.Messages.DoNotUnderstand) } } else if update.Message.Photo != nil { err := bot.handlePhoto(update.Message) if err != nil { 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 } 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 { err := bot.handleVideo(update.Message) if err != nil { 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 } bot.dispatchMessage(update.Message) - bot.Telegram.replyWithMessage(update.Message, viper.GetString("MsgThankYouMedia")) + bot.Telegram.replyWithMessage(update.Message, bot.Telegram.Messages.ThankYouMedia) } else { 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) { - 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) { album, err := bot.MediaStore.GetCurrentAlbum() if err != nil { 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 } 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 { - bot.Telegram.replyWithMessage(message, viper.GetString("MsgInfoNoAlbum")) + bot.Telegram.replyWithMessage(message, bot.Telegram.Messages.InfoNoAlbum) } } func (bot *PhotoBot) handleNewAlbumCommand(message *tgbotapi.Message) { - if len(message.Text) < 14 { - bot.Telegram.replyToCommandWithMessage(message, viper.GetString("MsgMissingAlbumName")) + if len(message.Text) < len(bot.Telegram.Commands.NewAlbum)+3 { + bot.Telegram.replyToCommandWithMessage(message, bot.Telegram.Messages.MissingAlbumName) return } albumName := message.CommandArguments() @@ -271,11 +321,11 @@ func (bot *PhotoBot) handleNewAlbumCommand(message *tgbotapi.Message) { err := bot.MediaStore.NewAlbum(albumName) if err != nil { 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 } - 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 { @@ -290,13 +340,3 @@ func (telegram *TelegramBackend) replyWithMessage(message *tgbotapi.Message, tex _, err := telegram.API.Send(msg) 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 -} diff --git a/go.mod b/go.mod index b9be3d3..ffb386a 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,21 @@ go 1.14 require ( 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/google/uuid v1.1.1 + github.com/gorilla/sessions v1.2.0 github.com/julienschmidt/httprouter v1.2.0 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/rakyll/statik v0.1.7 github.com/spf13/afero v1.1.2 github.com/spf13/viper v1.6.3 github.com/technoweenie/multipartstreamer v1.0.1 // indirect + golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be golang.org/x/text v0.3.2 + gopkg.in/square/go-jose.v2 v2.5.1 // indirect gopkg.in/yaml.v2 v2.2.8 ) diff --git a/go.sum b/go.sum index b2f585a..a193941 100644 --- a/go.sum +++ b/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/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/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-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= @@ -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/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/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/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/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= @@ -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/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/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.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 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-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-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco= 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/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= @@ -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/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 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.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/http.go b/http.go index 20b28d8..07e0a40 100644 --- a/http.go +++ b/http.go @@ -1,67 +1,14 @@ package main import ( - "html/template" - "io/ioutil" "log" "net/http" "path" - "sort" "strings" - "time" - _ "github.com/nmasse-itix/Telegram-Photo-Album-Bot/statik" "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 // relative components before processing. head will never contain a slash and // 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:] } -type WebInterface struct { - AlbumTemplate *template.Template - MediaTemplate *template.Template - IndexTemplate *template.Template -} - -func (bot *PhotoBot) ServeWebInterface(listenAddr string) { +func (bot *PhotoBot) ServeWebInterface(listenAddr string, frontend *SecurityFrontend) { statikFS, err := fs.New() if err != nil { log.Fatal(err) @@ -108,7 +49,10 @@ func (bot *PhotoBot) ServeWebInterface(listenAddr string) { router := http.NewServeMux() router.Handle("/js/", 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{ Addr: listenAddr, @@ -116,142 +60,3 @@ func (bot *PhotoBot) ServeWebInterface(listenAddr string) { } 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) -} diff --git a/main.go b/main.go index c1f735f..7a5b017 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,8 @@ package main import ( + "crypto" + "encoding/base64" "fmt" "log" "os" @@ -14,22 +16,47 @@ import ( func initConfig() { // 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 - viper.SetDefault("TelegramNewUpdateTimeout", 60) - viper.SetDefault("SiteName", "My photo album") - - // Default messages - viper.SetDefault("MsgForbidden", "Access Denied") - viper.SetDefault("MsgHelp", "Hello") - viper.SetDefault("MsgMissingAlbumName", "The album name is missing") - viper.SetDefault("MsgServerError", "Server Error") - viper.SetDefault("MsgAlbumCreated", "Album created") - viper.SetDefault("MsgDoNotUnderstand", "Unknown command") - viper.SetDefault("MsgInfoNoAlbum", "There is no album started, yet.") - viper.SetDefault("MsgNoUsername", "Sorry, you need to set your username") - viper.SetDefault("MsgThankYouMedia", "Got it, thanks!") - viper.SetDefault("MsgSendMeSomething", "OK. Send me something.") + viper.SetDefault("Telegram.NewUpdateTimeout", 60) + + // Telegram messages + viper.SetDefault("Telegram.Messages.Forbidden", "Access Denied") + viper.SetDefault("Telegram.Messages.Help", `Hello, I'm the photo bot! + + You can send me your photos and videos. + + To start an album, use "/newAlbum album name". + To get the current album name, use "/info". + To share an album, use "/share album". + To share all albums, use "/share". + If you are lost, you can get this message again with "/help". + + 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.AddConfigPath("/etc/photo-bot/") @@ -62,30 +89,86 @@ func validateConfig() { log.Fatalf("Cannot find target directory: %s: %s", targetDir, err) } - retryDelay := viper.GetInt("RetryDelay") + retryDelay := viper.GetInt("Telegram.RetryDelay") if retryDelay <= 0 { log.Fatal("The RetryDelay cannot be zero or negative!") } - timeout := viper.GetInt("TelegramNewUpdateTimeout") + timeout := viper.GetInt("Telegram.NewUpdateTimeout") if timeout <= 0 { log.Fatal("The TelegramNewUpdateTimeout cannot be zero or negative!") } - token := viper.GetString("TelegramToken") + token := viper.GetString("Telegram.Token") if token == "" { log.Fatal("No Telegram Bot Token provided!") } - listenAddr := viper.GetString("HttpListen") - if listenAddr == "" { - log.Fatal("No listen address provided!") - } - - authorizedUsersList := viper.GetStringSlice("AuthorizedUsers") + authorizedUsersList := viper.GetStringSlice("Telegram.AuthorizedUsers") if len(authorizedUsersList) == 0 { 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() { @@ -94,11 +177,15 @@ func main() { // Create the Bot photoBot := InitBot(viper.GetString("TargetDir")) - photoBot.Telegram.RetryDelay = time.Duration(viper.GetInt("RetryDelay")) * time.Second - photoBot.Telegram.NewUpdateTimeout = viper.GetInt("TelegramNewUpdateTimeout") + photoBot.Telegram.RetryDelay = time.Duration(viper.GetInt("Telegram.RetryDelay")) * time.Second + 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 - for _, item := range viper.GetStringSlice("AuthorizedUsers") { + for _, item := range viper.GetStringSlice("Telegram.AuthorizedUsers") { photoBot.Telegram.AuthorizedUsers[item] = true } @@ -107,7 +194,7 @@ func main() { fullPath := filepath.Join(targetDir, dir) var err error = os.MkdirAll(fullPath, 0777) 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 // Start the bot - photoBot.StartBot(viper.GetString("TelegramToken")) - photoBot.Telegram.API.Debug = viper.GetBool("TelegramDebug") + photoBot.StartBot(viper.GetString("Telegram.Token")) + 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() go photoBot.Process() - photoBot.ServeWebInterface(viper.GetString("HttpListen")) + photoBot.ServeWebInterface(viper.GetString("WebInterface.Listen"), securityFrontend) } diff --git a/secret.go b/secret.go new file mode 100644 index 0000000..9287a68 --- /dev/null +++ b/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) +} diff --git a/secret_test.go b/secret_test.go new file mode 100644 index 0000000..acfb411 --- /dev/null +++ b/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") +} diff --git a/security.go b/security.go new file mode 100644 index 0000000..3406b2c --- /dev/null +++ b/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 +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..520e879 --- /dev/null +++ b/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 +} diff --git a/token_test.go b/token_test.go new file mode 100644 index 0000000..9af1cec --- /dev/null +++ b/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") + } + +} diff --git a/user.go b/user.go new file mode 100644 index 0000000..43bc5b4 --- /dev/null +++ b/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) +} diff --git a/web.go b/web.go new file mode 100644 index 0000000..0b4d06d --- /dev/null +++ b/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) +}