12 changed files with 1024 additions and 264 deletions
@ -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) |
||||
|
} |
||||
@ -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") |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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") |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
@ -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…
Reference in new issue