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"
"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
}

6
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
)

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/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=

205
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)
}

199
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)
}

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