Compare commits

..

7 Commits

4 changed files with 416 additions and 275 deletions

328
cmd/eksterd/http.go Normal file
View File

@ -0,0 +1,328 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/alecthomas/template"
"github.com/garyburd/redigo/redis"
"github.com/pstuifzand/ekster/pkg/indieauth"
"github.com/pstuifzand/ekster/pkg/microsub"
"github.com/pstuifzand/ekster/pkg/util"
)
type mainHandler struct {
Backend *memoryBackend
Templates *template.Template
}
type session struct {
AuthorizationEndpoint string `redis:"authorization_endpoint"`
Me string `redis:"me"`
RedirectURI string `redis:"redirect_uri"`
State string `redis:"state"`
LoggedIn bool `redis:"logged_in"`
}
type authResponse struct {
Me string `json:"me"`
}
type indexPage struct {
Session session
}
type settingsPage struct {
Session session
CurrentChannel string
Channels map[string]microsub.Channel
Feeds map[string][]microsub.Feed
}
func newMainHandler(backend *memoryBackend) (*mainHandler, error) {
h := &mainHandler{Backend: backend}
templateDir := os.Getenv("EKSTER_TEMPLATES")
if templateDir == "" {
return nil, fmt.Errorf("Missing env var EKSTER_TEMPLATES")
}
templateDir = strings.TrimRight(templateDir, "/")
templates, err := template.ParseGlob(fmt.Sprintf("%s/*.html", templateDir))
if err != nil {
return nil, err
}
h.Templates = templates
return h, nil
}
func getSessionCookie(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie("session")
sessionVar := util.RandStringBytes(16)
if err == http.ErrNoCookie {
newCookie := &http.Cookie{
Name: "session",
Value: sessionVar,
Expires: time.Now().Add(24 * time.Hour),
}
http.SetCookie(w, newCookie)
} else {
sessionVar = c.Value
}
return sessionVar
}
func loadSession(sessionVar string, conn redis.Conn) (session, error) {
var sess session
sessionKey := "session:" + sessionVar
data, err := redis.Values(conn.Do("HGETALL", sessionKey))
if err != nil {
return sess, err
}
err = redis.ScanStruct(data, &sess)
if err != nil {
return sess, err
}
return sess, nil
}
func saveSession(sessionVar string, sess *session, conn redis.Conn) error {
_, err := conn.Do("HMSET", redis.Args{}.Add("session:"+sessionVar).AddFlat(sess)...)
return err
}
func verifyAuthCode(code, redirectURI, authEndpoint string) (bool, *authResponse, error) {
reqData := url.Values{}
reqData.Set("code", code)
reqData.Set("client_id", ClientID)
reqData.Set("redirect_uri", redirectURI)
req, err := http.NewRequest(http.MethodPost, authEndpoint, strings.NewReader(reqData.Encode()))
if err != nil {
return false, nil, err
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return false, nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
input := io.TeeReader(resp.Body, os.Stderr)
dec := json.NewDecoder(input)
var authResponse authResponse
err = dec.Decode(&authResponse)
if err != nil {
return false, nil, err
}
return true, &authResponse, nil
}
return false, nil, fmt.Errorf("ERROR: HTTP response code from authorization_endpoint (%s) %d \n", authEndpoint, resp.StatusCode)
}
func (h *mainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn := pool.Get()
defer conn.Close()
err := r.ParseForm()
if err != nil {
log.Println(err)
http.Error(w, fmt.Sprintf("Bad Request: %s", err.Error()), 400)
return
}
if r.Method == http.MethodGet {
if r.URL.Path == "/" {
sessionVar := getSessionCookie(w, r)
sess, err := loadSession(sessionVar, conn)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
var page indexPage
page.Session = sess
err = h.Templates.ExecuteTemplate(w, "index.html", page)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
return
} else if r.URL.Path == "/auth/callback" {
c, err := r.Cookie("session")
if err == http.ErrNoCookie {
http.Redirect(w, r, "/", 302)
return
}
sessionVar := c.Value
sess, err := loadSession(sessionVar, conn)
state := r.Form.Get("state")
if state != sess.State {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "ERROR: Mismatched state\n")
return
}
code := r.Form.Get("code")
verified, authResponse, err := verifyAuthCode(code, sess.RedirectURI, sess.AuthorizationEndpoint)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
if verified {
sess.Me = authResponse.Me
sess.LoggedIn = true
saveSession(sessionVar, &sess, conn)
http.Redirect(w, r, "/", 302)
return
}
return
} else if r.URL.Path == "/settings/channel" {
c, err := r.Cookie("session")
if err == http.ErrNoCookie {
http.Redirect(w, r, "/", 302)
return
}
sessionVar := c.Value
sess, err := loadSession(sessionVar, conn)
if !sess.LoggedIn {
w.WriteHeader(401)
fmt.Fprintf(w, "Unauthorized")
return
}
var page settingsPage
page.Session = sess
page.Channels = h.Backend.Channels
page.Feeds = h.Backend.Feeds
page.CurrentChannel = r.URL.Query().Get("uid")
err = h.Templates.ExecuteTemplate(w, "channel.html", page)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
return
} else if r.URL.Path == "/settings" {
c, err := r.Cookie("session")
if err == http.ErrNoCookie {
http.Redirect(w, r, "/", 302)
return
}
sessionVar := c.Value
sess, err := loadSession(sessionVar, conn)
if !sess.LoggedIn {
w.WriteHeader(401)
fmt.Fprintf(w, "Unauthorized")
return
}
var page settingsPage
page.Session = sess
page.Channels = h.Backend.Channels
page.Feeds = h.Backend.Feeds
err = h.Templates.ExecuteTemplate(w, "settings.html", page)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
return
}
} else if r.Method == http.MethodPost {
if r.URL.Path == "/auth" {
c, err := r.Cookie("session")
if err == http.ErrNoCookie {
http.Redirect(w, r, "/", 302)
return
}
sessionVar := c.Value
// redirect to endpoint
me := r.Form.Get("url")
log.Println(me)
meURL, err := url.Parse(me)
if err != nil {
http.Error(w, fmt.Sprintf("Bad Request: %s, %s", err.Error(), me), 400)
return
}
endpoints, err := indieauth.GetEndpoints(meURL)
if err != nil {
http.Error(w, fmt.Sprintf("Bad Request: %s %s", err.Error(), me), 400)
return
}
log.Println(endpoints)
authURL, err := url.Parse(endpoints.AuthorizationEndpoint)
if err != nil {
http.Error(w, fmt.Sprintf("Bad Request: %s %s", err.Error(), me), 400)
return
}
log.Println(authURL)
state := util.RandStringBytes(16)
redirectURI := fmt.Sprintf("%s/auth/callback", os.Getenv("EKSTER_BASEURL"))
sess := session{
AuthorizationEndpoint: endpoints.AuthorizationEndpoint,
Me: meURL.String(),
State: state,
RedirectURI: redirectURI,
LoggedIn: false,
}
saveSession(sessionVar, &sess, conn)
q := authURL.Query()
q.Add("response_type", "id")
q.Add("me", meURL.String())
q.Add("client_id", ClientID)
q.Add("redirect_uri", redirectURI)
q.Add("state", state)
authURL.RawQuery = q.Encode()
http.Redirect(w, r, authURL.String(), 302)
return
} else if r.URL.Path == "/auth/logout" {
c, err := r.Cookie("session")
if err == http.ErrNoCookie {
http.Redirect(w, r, "/", 302)
return
}
sessionVar := c.Value
conn.Do("DEL", "session:"+sessionVar)
http.Redirect(w, r, "/", 302)
return
}
}
http.NotFound(w, r)
}

View File

@ -18,23 +18,20 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/garyburd/redigo/redis"
"github.com/pstuifzand/ekster/pkg/indieauth"
"github.com/pstuifzand/ekster/pkg/microsub"
"github.com/pstuifzand/ekster/pkg/util"
)
const (
ClientID string = "https://p83.nl/microsub-client"
)
var (
@ -52,270 +49,6 @@ func init() {
flag.BoolVar(&auth, "auth", true, "use auth")
}
type mainHandler struct {
Backend *memoryBackend
}
type session struct {
AuthorizationEndpoint string `redis:"authorization_endpoint"`
Me string `redis:"me"`
RedirectURI string `redis:"redirect_uri"`
State string `redis:"state"`
ClientID string `redis:"client_id"`
LoggedIn bool `redis:"logged_in"`
}
type authResponse struct {
Me string `json:"me"`
}
type indexPage struct {
Session session
}
type settingsPage struct {
Session session
}
func (h *mainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn := pool.Get()
defer conn.Close()
err := r.ParseForm()
if err != nil {
log.Println(err)
http.Error(w, fmt.Sprintf("Bad Request: %s", err.Error()), 400)
return
}
if r.Method == http.MethodGet {
if r.URL.Path == "/" {
c, err := r.Cookie("session")
sessionVar := util.RandStringBytes(16)
if err == http.ErrNoCookie {
newCookie := &http.Cookie{
Name: "session",
Value: sessionVar,
Expires: time.Now().Add(24 * time.Hour),
}
http.SetCookie(w, newCookie)
} else {
sessionVar = c.Value
}
var sess session
sessionKey := "session:" + sessionVar
data, err := redis.Values(conn.Do("HGETALL", sessionKey))
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
err = redis.ScanStruct(data, &sess)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
t, err := template.ParseFiles(
os.Getenv("EKSTER_TEMPLATES") + "/index.html",
)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
var page indexPage
page.Session = sess
err = t.ExecuteTemplate(w, "index.html", page)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
return
} else if r.URL.Path == "/auth/callback" {
c, err := r.Cookie("session")
if err == http.ErrNoCookie {
http.Redirect(w, r, "/", 302)
return
}
sessionVar := c.Value
var sess session
sessionKey := "session:" + sessionVar
data, err := redis.Values(conn.Do("HGETALL", sessionKey))
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
err = redis.ScanStruct(data, &sess)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
state := r.Form.Get("state")
if state != sess.State {
fmt.Fprintf(w, "ERROR: Mismatched state\n")
return
}
code := r.Form.Get("code")
reqData := url.Values{}
reqData.Set("code", code)
reqData.Set("client_id", sess.ClientID)
reqData.Set("redirect_uri", sess.RedirectURI)
// resp, err := http.PostForm(sess.AuthorizationEndpoint, reqData)
req, err := http.NewRequest(http.MethodPost, sess.AuthorizationEndpoint, strings.NewReader(reqData.Encode()))
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
log.Println(req)
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
input := io.TeeReader(resp.Body, os.Stderr)
dec := json.NewDecoder(input)
var authResponse authResponse
err = dec.Decode(&authResponse)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
log.Println(authResponse)
sess.Me = authResponse.Me
sess.LoggedIn = true
conn.Do("HMSET", redis.Args{}.Add(sessionKey).AddFlat(sess)...)
http.Redirect(w, r, "/", 302)
return
} else {
fmt.Fprintf(w, "ERROR: HTTP response code from authorization_endpoint (%s) %d \n", sess.AuthorizationEndpoint, resp.StatusCode)
return
}
} else if r.URL.Path == "/settings" {
c, err := r.Cookie("session")
if err == http.ErrNoCookie {
http.Redirect(w, r, "/", 302)
return
}
sessionVar := c.Value
var sess session
sessionKey := "session:" + sessionVar
data, err := redis.Values(conn.Do("HGETALL", sessionKey))
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
err = redis.ScanStruct(data, &sess)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
t, err := template.ParseFiles(
os.Getenv("EKSTER_TEMPLATES") + "/settings.html",
)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
var page settingsPage
page.Session = sess
err = t.ExecuteTemplate(w, "settings.html", page)
if err != nil {
fmt.Fprintf(w, "ERROR: %q\n", err)
return
}
return
}
} else if r.Method == http.MethodPost {
if r.URL.Path == "/auth" {
c, err := r.Cookie("session")
if err == http.ErrNoCookie {
http.Redirect(w, r, "/", 302)
return
}
sessionVar := c.Value
// redirect to endpoint
me := r.Form.Get("url")
log.Println(me)
meURL, err := url.Parse(me)
if err != nil {
http.Error(w, fmt.Sprintf("Bad Request: %s, %s", err.Error(), me), 400)
return
}
endpoints, err := indieauth.GetEndpoints(meURL)
if err != nil {
http.Error(w, fmt.Sprintf("Bad Request: %s %s", err.Error(), me), 400)
return
}
log.Println(endpoints)
authURL, err := url.Parse(endpoints.AuthorizationEndpoint)
if err != nil {
http.Error(w, fmt.Sprintf("Bad Request: %s %s", err.Error(), me), 400)
return
}
log.Println(authURL)
state := util.RandStringBytes(16)
clientID := "https://p83.nl/microsub-client"
redirectURI := fmt.Sprintf("%s/auth/callback", os.Getenv("EKSTER_BASEURL"))
sess := session{
AuthorizationEndpoint: endpoints.AuthorizationEndpoint,
Me: meURL.String(),
State: state,
RedirectURI: redirectURI,
ClientID: clientID,
LoggedIn: false,
}
conn.Do("HMSET", redis.Args{}.Add("session:"+sessionVar).AddFlat(&sess)...)
q := authURL.Query()
q.Add("response_type", "id")
q.Add("me", meURL.String())
q.Add("client_id", clientID)
q.Add("redirect_uri", redirectURI)
q.Add("state", state)
authURL.RawQuery = q.Encode()
http.Redirect(w, r, authURL.String(), 302)
return
} else if r.URL.Path == "/auth/logout" {
c, err := r.Cookie("session")
if err == http.ErrNoCookie {
http.Redirect(w, r, "/", 302)
return
}
sessionVar := c.Value
conn.Do("DEL", "session:"+sessionVar)
http.Redirect(w, r, "/", 302)
return
}
}
http.NotFound(w, r)
}
func newPool(addr string) *redis.Pool {
return &redis.Pool{
MaxIdle: 3,
@ -372,10 +105,11 @@ func main() {
http.Handle("/incoming/", &incomingHandler{
Backend: &hubBackend,
})
http.Handle("/", &mainHandler{
Backend: backend.(*memoryBackend),
})
handler, err := newMainHandler(backend.(*memoryBackend))
if err != nil {
log.Fatal(err)
}
http.Handle("/", handler)
backend.(*memoryBackend).run()
hubBackend.run()

61
templates/channel.html Normal file
View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ekster</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.1/css/bulma.min.css">
</head>
<body>
<section class="section">
<div class="container">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
Ekster
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="menu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
{{ if .Session.LoggedIn }}
<div id="menu" class="navbar-menu">
<a class="navbar-item" href="/settings">
Settings
</a>
<a class="navbar-item" href="{{ .Session.Me }}">
Profile
</a>
</div>
{{ end }}
</nav>
<h1 class="title">Ekster - Microsub server</h1>
{{ if .Session.LoggedIn }}
{{ end }}
<h2 class="subtitle">Channel</h2>
<div class="channel">
{{ range index .Feeds .CurrentChannel }}
<div class="feed box">
<div class="name">
<a href="{{ .URL }}">{{ or .Name "Untitled" }}</a>
</div>
</div>
{{ else }}
<div class="no-channels">No feeds</div>
{{ end }}
</div>
</div>
</section>
</body>
</html>

View File

@ -40,6 +40,24 @@
{{ if .Session.LoggedIn }}
{{ end }}
<h2 class="subtitle">Channels</h2>
<div class="channels">
{{ range .Channels }}
<div class="channel box">
<div class="name">
<a href="/settings/channel?uid={{ .UID }}">
{{ .Name }}
<span class="unread tag is-dark">{{ index $.Feeds .UID | len }}</span>
</a>
</div>
</div>
{{ else }}
<div class="no-channels">No channels</div>
{{ end }}
</div>
</div>
</section>
</body>