Compare commits
23 Commits
main
...
mastodon-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e41944cd82
|
||
|
|
64f15565fb
|
||
|
|
bd677c9613
|
||
|
|
24125aa767
|
||
|
|
ae3dae1ffa
|
||
|
|
cd2f58a24c
|
||
|
|
e663abe150
|
||
|
|
518d04fa72
|
||
|
|
046e0f9259
|
||
|
|
2b5366d407
|
||
|
|
dbfb7e547d
|
||
|
|
c3dc549ceb
|
||
|
|
eb52dd73d1
|
||
|
|
9dd4681857
|
||
|
|
294b943353
|
||
|
|
63d71cd625
|
||
|
|
8dcc720e9f
|
||
|
|
e2518662c9
|
||
|
|
6e7d8aeedb
|
||
|
|
bc05eab477
|
||
|
|
8a4d9dde1d
|
||
|
|
7ab8527a44
|
||
|
|
10eaaf4d5f
|
@@ -1,3 +1,4 @@
|
||||
CRYPTO_COOKIE_KEY=****************
|
||||
TWITTER_CLIENT_ID=**********************************
|
||||
TWITTER_CLIENT_SECRET=**************************************************
|
||||
INSTANCES_SOCIAL_TOKEN=********************************************************************************************************************************
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"slices"
|
||||
"extrovert/internals"
|
||||
"fmt"
|
||||
"errors"
|
||||
)
|
||||
@@ -87,7 +86,7 @@ func getInstanceList(limit int) []Instance {
|
||||
log.Printf("WARN: Unable to fetch Mastodon instance datalist due to:\n%s\n\nFall backing into static list.", err.Error())
|
||||
return INSTANCES
|
||||
}
|
||||
return internals.RemoveDuplicates(slices.Concat(INSTANCES, i))
|
||||
return slices.Concat(INSTANCES, i)
|
||||
}
|
||||
|
||||
templ InstancesOptions(limit int) {
|
||||
|
||||
@@ -15,7 +15,7 @@ var loginUrl = fmt.Sprintf("https://x.com/i/oauth2/authorize"+
|
||||
"&code_challenge=challenge"+
|
||||
"&code_challenge_method=plain",
|
||||
os.Getenv("TWITTER_CLIENT_ID"),
|
||||
url.PathEscape("http://localhost:7331/api/oauth/twitter"),
|
||||
url.PathEscape("http://localhost:7331/api/twitter/oauth"),
|
||||
)
|
||||
|
||||
templ LoginTwitter() {
|
||||
@@ -36,16 +36,27 @@ templ LoginMastodon() {
|
||||
></button>
|
||||
<label for="instance-url">Choose a instance</label>
|
||||
</header>
|
||||
<input
|
||||
type="url"
|
||||
name="instance-url"
|
||||
placeholder="Instance Url"
|
||||
aria-label="Instance Url"
|
||||
list="instance-suggestions"
|
||||
/>
|
||||
<datalist id="instance-suggestions">
|
||||
@InstancesOptions(20)
|
||||
</datalist>
|
||||
<form
|
||||
autocomplete="on"
|
||||
method="post"
|
||||
action="/api/mastodon/apps"
|
||||
enctype="application/x-www-form-urlencoded"
|
||||
>
|
||||
<fieldset role="group">
|
||||
<input
|
||||
type="url"
|
||||
name="instance-url"
|
||||
placeholder="https://mastodon.social"
|
||||
aria-label="Instance Url"
|
||||
list="instance-suggestions"
|
||||
required
|
||||
/>
|
||||
<datalist id="instance-suggestions">
|
||||
@InstancesOptions(20)
|
||||
</datalist>
|
||||
<button>Login</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
35
internals/app/oauth.go
Normal file
35
internals/app/oauth.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"extrovert/internals/oauth"
|
||||
"net/url"
|
||||
"os"
|
||||
)
|
||||
|
||||
const DOMAIN = "http://localhost:7331/"
|
||||
|
||||
const TWITTER_REDIRECT = "/api/twitter/oauth2"
|
||||
|
||||
var TWITTER_APP = func() oauth.TwitterOAuth {
|
||||
ru, _ := url.Parse(DOMAIN)
|
||||
ru = ru.JoinPath(TWITTER_REDIRECT)
|
||||
|
||||
c := oauth.NewTwitterOAuth(
|
||||
os.Getenv("TWITTER_CLIENT_ID"),
|
||||
os.Getenv("TWITTER_CLIENT_SECRET"),
|
||||
ru,
|
||||
)
|
||||
|
||||
return c
|
||||
}()
|
||||
|
||||
const MASTODON_REDIRECT = "/api/mastodon/oauth2"
|
||||
|
||||
var MASTODON_APP = func() *oauth.MastodonOAuthClient {
|
||||
ru, _ := url.Parse(DOMAIN)
|
||||
ru = ru.JoinPath(MASTODON_REDIRECT)
|
||||
|
||||
c := oauth.NewMastodonOAuthClient(ru)
|
||||
|
||||
return c
|
||||
}()
|
||||
@@ -1,4 +0,0 @@
|
||||
package internals
|
||||
|
||||
const APP_VERSION = "1"
|
||||
const APP_NAME = "project-extrovert"
|
||||
228
internals/cookies/encoding.go
Normal file
228
internals/cookies/encoding.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package cookies
|
||||
|
||||
import (
|
||||
e "errors"
|
||||
"fmt"
|
||||
"math/bits"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TypedCookie[T any] struct {
|
||||
http.Cookie
|
||||
Name string
|
||||
TypedValue T
|
||||
}
|
||||
|
||||
func Marshal[T any](c TypedCookie[T]) (http.Cookie, error) {
|
||||
rv := reflect.ValueOf(c.TypedValue)
|
||||
if rv.Kind() == reflect.Pointer {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
|
||||
v := []string{}
|
||||
err := forEachField(&rv, func(fv *reflect.Value, ft *reflect.StructField) error {
|
||||
t := ft.Tag.Get("cookie")
|
||||
if t == "" {
|
||||
t = ft.Name
|
||||
}
|
||||
tv := strings.Split(t, ",")
|
||||
|
||||
value, err := encodeString(*fv)
|
||||
if err != nil {
|
||||
return e.Join(e.New("Unsupported type in struct: "+fv.Kind().String()), err)
|
||||
}
|
||||
|
||||
v = append(v, tv[0]+":"+value)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return http.Cookie{
|
||||
Name: c.Name,
|
||||
Value: strings.Join(v, "|"),
|
||||
Path: c.Path,
|
||||
Domain: c.Domain,
|
||||
Expires: c.Expires,
|
||||
RawExpires: c.RawExpires,
|
||||
MaxAge: c.MaxAge,
|
||||
Secure: c.Secure,
|
||||
HttpOnly: c.HttpOnly,
|
||||
SameSite: c.SameSite,
|
||||
}, err
|
||||
}
|
||||
|
||||
func Unmarshal[T any](data http.Cookie, v *TypedCookie[T]) error {
|
||||
if reflect.ValueOf(v).Kind() != reflect.Pointer {
|
||||
return e.New("`v` is not a pointer: " + reflect.ValueOf(v).Kind().String())
|
||||
}
|
||||
if reflect.TypeOf(&v.TypedValue) == nil {
|
||||
return e.New("TypedCookie.TypedValue is not a valid struct type")
|
||||
}
|
||||
|
||||
m := make(map[string]string)
|
||||
for _, pair := range strings.Split(data.Value, "|") {
|
||||
pairV := strings.Split(pair, ":")
|
||||
if len(pairV) == 0 {
|
||||
return e.New("Error trying to decode cookie value:\n" + data.Value + "\n\nMissing \":\" pair in first slice")
|
||||
}
|
||||
|
||||
key := pairV[0]
|
||||
|
||||
var value string
|
||||
if len(pairV) == 1 {
|
||||
value = ""
|
||||
} else {
|
||||
value = strings.Join(pairV[1:], ":")
|
||||
}
|
||||
|
||||
m[key] = value
|
||||
}
|
||||
|
||||
tv := reflect.ValueOf(&v.TypedValue)
|
||||
if tv.Kind() == reflect.Pointer {
|
||||
tv = tv.Elem()
|
||||
}
|
||||
|
||||
err := forEachField(&tv, func(fv *reflect.Value, ft *reflect.StructField) error {
|
||||
t := ft.Tag.Get("cookie")
|
||||
if t == "" {
|
||||
t = ft.Name
|
||||
}
|
||||
tk := strings.Split(t, ",")[0]
|
||||
|
||||
final, err := decodeString(m[tk], fv.Kind())
|
||||
if err != nil {
|
||||
return e.Join(e.New("Unsupported type in struct: "+fv.Kind().String()), err)
|
||||
}
|
||||
|
||||
kStr := strings.ToLower(fv.Kind().String())
|
||||
if strings.Contains(kStr, "complex") {
|
||||
fv.SetComplex(final.(complex128))
|
||||
|
||||
} else if strings.Contains(kStr, "uint") {
|
||||
fv.SetUint(final.(uint64))
|
||||
|
||||
} else if strings.Contains(kStr, "int") {
|
||||
fv.SetInt(final.(int64))
|
||||
|
||||
} else {
|
||||
fv.Set(reflect.ValueOf(final))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func forEachField(v *reflect.Value, callback func(fv *reflect.Value, ft *reflect.StructField) error) (err error) {
|
||||
t := v.Type()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("Panic while trying to loop through fields. Error:\n%v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
ft := t.Field(i)
|
||||
fv := v.FieldByName(ft.Name)
|
||||
|
||||
if fv.Kind() == reflect.Pointer {
|
||||
fv = fv.Elem()
|
||||
}
|
||||
|
||||
if !fv.IsValid() {
|
||||
return e.New("No such field: " + ft.Name)
|
||||
}
|
||||
|
||||
err = callback(&fv, &ft)
|
||||
if err != nil {
|
||||
return e.Join(e.New("Error while looping through value"), err)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func encodeString(v reflect.Value) (string, error) {
|
||||
switch v.Kind() {
|
||||
case reflect.Bool:
|
||||
return strconv.FormatBool(v.Bool()), nil
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return strconv.FormatInt(v.Int(), 10), nil
|
||||
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return strconv.FormatUint(v.Uint(), 10), nil
|
||||
|
||||
case reflect.Float32:
|
||||
return strconv.FormatFloat(v.Float(), 'g', -1, 32), nil
|
||||
case reflect.Float64:
|
||||
return strconv.FormatFloat(v.Float(), 'g', -1, 64), nil
|
||||
|
||||
case reflect.Complex64:
|
||||
return strconv.FormatComplex(v.Complex(), 'g', -1, 64), nil
|
||||
case reflect.Complex128:
|
||||
return strconv.FormatComplex(v.Complex(), 'g', -1, 128), nil
|
||||
|
||||
case reflect.String:
|
||||
return v.String(), nil
|
||||
|
||||
default:
|
||||
return "", e.ErrUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func decodeString(v string, k reflect.Kind) (any, error) {
|
||||
var final any
|
||||
var err error
|
||||
|
||||
switch k {
|
||||
case reflect.Bool:
|
||||
final, err = strconv.ParseBool(v)
|
||||
|
||||
case reflect.Int8:
|
||||
final, err = strconv.ParseInt(v, 10, 8)
|
||||
case reflect.Int16:
|
||||
final, err = strconv.ParseInt(v, 10, 16)
|
||||
case reflect.Int32:
|
||||
final, err = strconv.ParseInt(v, 10, 32)
|
||||
case reflect.Int64:
|
||||
final, err = strconv.ParseInt(v, 10, 64)
|
||||
case reflect.Int:
|
||||
final, err = strconv.ParseInt(v, 10, bits.UintSize)
|
||||
|
||||
case reflect.Uint8:
|
||||
final, err = strconv.ParseUint(v, 10, 8)
|
||||
case reflect.Uint16:
|
||||
final, err = strconv.ParseUint(v, 10, 16)
|
||||
case reflect.Uint32:
|
||||
final, err = strconv.ParseUint(v, 10, 32)
|
||||
case reflect.Uint64:
|
||||
final, err = strconv.ParseUint(v, 10, 64)
|
||||
case reflect.Uint:
|
||||
final, err = strconv.ParseUint(v, 10, bits.UintSize)
|
||||
|
||||
case reflect.Float32:
|
||||
final, err = strconv.ParseFloat(v, 32)
|
||||
case reflect.Float64:
|
||||
final, err = strconv.ParseFloat(v, 64)
|
||||
|
||||
case reflect.Complex64:
|
||||
final, err = strconv.ParseComplex(v, 64)
|
||||
case reflect.Complex128:
|
||||
final, err = strconv.ParseComplex(v, 128)
|
||||
|
||||
case reflect.String:
|
||||
final = v
|
||||
|
||||
default:
|
||||
return nil, e.ErrUnsupported
|
||||
}
|
||||
|
||||
return final, err
|
||||
}
|
||||
76
internals/cookies/encoding_test.go
Normal file
76
internals/cookies/encoding_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package cookies
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type TestType struct {
|
||||
FirstValue string `cookie:"first_value"`
|
||||
SecondValue string `cookie:"second_value"`
|
||||
IntValue int `cookie:"int_value"`
|
||||
UintValue uint `cookie:"uint_value"`
|
||||
BoolValue bool `cookie:"bool_value"`
|
||||
FloatValue float64 `cookie:"float_value"`
|
||||
ComplexValue complex64 `cookie:"complex_value"`
|
||||
}
|
||||
|
||||
var TEST_VALUE = TestType{
|
||||
FirstValue: "Hello world",
|
||||
SecondValue: "This is a test",
|
||||
IntValue: -10,
|
||||
UintValue: 13,
|
||||
BoolValue: true,
|
||||
FloatValue: 3.14159,
|
||||
ComplexValue: -43110.70519,
|
||||
}
|
||||
|
||||
var TEST_TYPED_COOKIE = TypedCookie[TestType]{
|
||||
TypedValue: TEST_VALUE,
|
||||
}
|
||||
|
||||
var TEST_STRING = strings.Join([]string{
|
||||
"first_value:Hello world",
|
||||
"second_value:This is a test",
|
||||
"int_value:-10",
|
||||
"uint_value:13",
|
||||
"bool_value:true",
|
||||
"float_value:3.14159",
|
||||
"complex_value:(-43110.707+0i)",
|
||||
}, "|")
|
||||
|
||||
func TestMarshal(t *testing.T) {
|
||||
s, err := Marshal(TEST_TYPED_COOKIE)
|
||||
if err != nil {
|
||||
t.Fatalf("Error trying to parse value:\n%s", err.Error())
|
||||
}
|
||||
|
||||
if s.Value != TEST_STRING {
|
||||
t.Fatalf("Assertion failed, expected:\n%s\n\nfound:\n%s", TEST_STRING, s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal(t *testing.T) {
|
||||
var tc TypedCookie[TestType]
|
||||
err := Unmarshal(http.Cookie{Value: TEST_STRING}, &tc)
|
||||
if err != nil {
|
||||
t.Fatalf("Error trying to parse value:\n%s", err.Error())
|
||||
}
|
||||
|
||||
if tc.TypedValue != TEST_VALUE {
|
||||
t.Fatalf("Assertion failed, expected:\n%v\n\nfound:\n%v", TEST_VALUE, tc.TypedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanicUnmarshal(t *testing.T) {
|
||||
type Private struct {
|
||||
//nolint:unused
|
||||
privateField string `cookie:"private"`
|
||||
}
|
||||
var tc TypedCookie[Private]
|
||||
err := Unmarshal(http.Cookie{Value: "private:Hello world"}, &tc)
|
||||
if err == nil {
|
||||
t.Fatalf("Function did not recover from panic, resulting value:\n%v", tc)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package internals
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func RemoveDuplicates[T comparable](slice []T) []T {
|
||||
keys := make(map[T]bool)
|
||||
list := []T{}
|
||||
for _, entry := range slice {
|
||||
if _, value := keys[entry]; !value {
|
||||
keys[entry] = true
|
||||
list = append(list, entry)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func GetCookie(name string, w http.ResponseWriter, r *http.Request) *http.Cookie {
|
||||
name = fmt.Sprintf("__Host-%s-%s-%s", APP_NAME, APP_VERSION, name)
|
||||
|
||||
c := r.Cookies()
|
||||
i := slices.IndexFunc(c, func(c *http.Cookie) bool {
|
||||
return c.Name == name
|
||||
})
|
||||
var cookie *http.Cookie
|
||||
if i == -1 {
|
||||
cookie = &http.Cookie{
|
||||
Name: name,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
}
|
||||
} else {
|
||||
cookie = c[i]
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func HttpErrorHelper(w http.ResponseWriter) func(msg string, err error, status int) bool {
|
||||
return func(msg string, err error, status int) bool {
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
_, err = w.Write([]byte(msg + "\n Error: " + err.Error()))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Error trying to return error code (somehow):\n" + err.Error()))
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package internals
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
handler http.Handler
|
||||
dev bool
|
||||
noCache bool
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
m.logger.Printf("Handling request. path=%s", r.URL.Path)
|
||||
|
||||
if m.dev {
|
||||
r.URL.Scheme = "http"
|
||||
} else {
|
||||
r.URL.Scheme = "https"
|
||||
}
|
||||
|
||||
m.handler.ServeHTTP(w, r)
|
||||
|
||||
if m.noCache {
|
||||
w.Header().Del("Cache-Control")
|
||||
w.Header().Add("Cache-Control", "max-age=0")
|
||||
}
|
||||
}
|
||||
|
||||
func NewMiddleware(handler http.Handler, dev bool, noCache bool, logger *log.Logger) *Middleware {
|
||||
return &Middleware{handler, dev, noCache, logger}
|
||||
}
|
||||
125
internals/middlewares/cookies_crypto.go
Normal file
125
internals/middlewares/cookies_crypto.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CookiesCryptoMiddleware struct {
|
||||
key string
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewCookiesCryptoMiddleware(key string, logger *log.Logger) CookiesCryptoMiddleware {
|
||||
return CookiesCryptoMiddleware{key, logger}
|
||||
}
|
||||
|
||||
func (m CookiesCryptoMiddleware) Serve(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m.decryptCookies(r)
|
||||
handler(w, r)
|
||||
m.encryptCookies(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (m CookiesCryptoMiddleware) encrypt(pt string) (string, error) {
|
||||
aes, err := aes.NewCipher([]byte(m.key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(aes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
_, err = rand.Read(nonce)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ct := gcm.Seal(nonce, nonce, []byte(pt), nil)
|
||||
|
||||
return base64.URLEncoding.EncodeToString(ct), nil
|
||||
}
|
||||
|
||||
func (m CookiesCryptoMiddleware) encryptCookies(w http.ResponseWriter) {
|
||||
cookies := w.Header().Values("Set-Cookie")
|
||||
w.Header().Del("Set-Cookie")
|
||||
for _, c := range cookies {
|
||||
cv := strings.Split(c, ";")
|
||||
|
||||
var c, attrs string
|
||||
if len(cv) > 1 {
|
||||
c, attrs = cv[0], strings.Join(cv[1:], ";")
|
||||
} else {
|
||||
c = cv[0]
|
||||
}
|
||||
|
||||
cn, v := strings.Split(c, "=")[0], strings.Split(c, "=")[1]
|
||||
|
||||
v, err := m.encrypt(strings.Trim(v, "\""))
|
||||
if err != nil {
|
||||
m.logger.Panicf(
|
||||
"ERRO: Unable to encrypt cookie \"%s\", skipping. Error: %s",
|
||||
cn, err.Error(),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
c = cn + "=\"" + v + "\";" + attrs
|
||||
|
||||
w.Header().Add("Set-Cookie", c)
|
||||
}
|
||||
}
|
||||
|
||||
func (m CookiesCryptoMiddleware) decrypt(ct string) (string, error) {
|
||||
cb, err := base64.URLEncoding.DecodeString(ct)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ct = string(cb)
|
||||
|
||||
aes, err := aes.NewCipher([]byte(m.key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(aes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
nonce, ct := ct[:nonceSize], ct[nonceSize:]
|
||||
|
||||
pt, err := gcm.Open(nil, []byte(nonce), []byte(ct), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
func (m CookiesCryptoMiddleware) decryptCookies(r *http.Request) {
|
||||
rcookies := r.Cookies()
|
||||
r.Header.Del("Cookie")
|
||||
for _, c := range rcookies {
|
||||
cv, err := m.decrypt(c.Value)
|
||||
if err != nil {
|
||||
m.logger.Panicf(
|
||||
"ERRO: Unable to decrypt cookie \"%s\", skipping: Error: %s",
|
||||
c.Name, err.Error(),
|
||||
)
|
||||
continue
|
||||
}
|
||||
c.Value = cv
|
||||
r.AddCookie(c)
|
||||
}
|
||||
}
|
||||
115
internals/middlewares/cookies_crypto_test.go
Normal file
115
internals/middlewares/cookies_crypto_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
// "extrovert/internals/cookies"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const KEY = "AGVSBG93B3JSZAAA"
|
||||
|
||||
const STRING = "Hello world, I'm a Cookie"
|
||||
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
c, _ := r.Cookie("__Host-test")
|
||||
|
||||
if c.Value != STRING {
|
||||
log.Fatalf(
|
||||
"Request cookie wasn't correctly decrypted.\nOriginal: %s\nDecrypted: %s",
|
||||
STRING, c.Value,
|
||||
)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "__Host-response-test",
|
||||
Value: STRING,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteDefaultMode,
|
||||
Path: "/",
|
||||
MaxAge: 2000,
|
||||
Domain: "localhost",
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequest(t *testing.T) {
|
||||
m := NewCookiesCryptoMiddleware(KEY, log.Default())
|
||||
|
||||
e, err := m.encrypt(STRING)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var COOKIE = http.Cookie{
|
||||
Name: "__Host-test",
|
||||
Value: e,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteDefaultMode,
|
||||
Path: "/",
|
||||
MaxAge: 2000,
|
||||
Domain: "localhost",
|
||||
HttpOnly: true,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "https://localhost:3030", nil)
|
||||
req.AddCookie(&COOKIE)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h := m.Serve(handler)
|
||||
h(w, req)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
log.Printf("%#v", w.Header().Values("Set-Cookie"))
|
||||
|
||||
res := w.Result()
|
||||
cs := res.Cookies()
|
||||
log.Print(cs)
|
||||
ci := slices.IndexFunc(cs, func(c *http.Cookie) bool {
|
||||
return c.Name == "__Host-response-test"
|
||||
})
|
||||
c := cs[ci]
|
||||
|
||||
log.Print(c)
|
||||
|
||||
d, err := m.decrypt(c.Value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if d != STRING {
|
||||
log.Fatalf(
|
||||
"Response cookie wasn't correctly encrypted.\nOriginal: %s\nEncrypted: %s",
|
||||
STRING, d,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncrypt(t *testing.T) {
|
||||
m := NewCookiesCryptoMiddleware(KEY, log.Default())
|
||||
|
||||
e, err := m.encrypt(STRING)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log.Printf("Encrypted %s", e)
|
||||
|
||||
d, err := m.decrypt(e)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log.Printf("Decrypted %s", d)
|
||||
|
||||
if d != STRING {
|
||||
log.Fatalf("Decrypted value isn't equal to original.\n"+
|
||||
"Original: %s\n"+
|
||||
"Decrypted: %s\n"+
|
||||
"\n"+
|
||||
"Encrypted: %s\n",
|
||||
STRING, d, e)
|
||||
}
|
||||
}
|
||||
25
internals/middlewares/development.go
Normal file
25
internals/middlewares/development.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type DevelopmentMiddleware struct {
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewDevelopmentMiddleware(logger *log.Logger) DevelopmentMiddleware {
|
||||
return DevelopmentMiddleware{logger}
|
||||
}
|
||||
|
||||
func (m DevelopmentMiddleware) Serve(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m.logger.Printf("New request: %s", r.URL.Path)
|
||||
|
||||
handler(w, r)
|
||||
|
||||
w.Header().Del("Cache-Control")
|
||||
w.Header().Add("Cache-Control", "max-age=0")
|
||||
}
|
||||
}
|
||||
359
internals/oauth/mastodon.templ
Normal file
359
internals/oauth/mastodon.templ
Normal file
@@ -0,0 +1,359 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
e "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"extrovert/components"
|
||||
"extrovert/internals/router/errors"
|
||||
"extrovert/templates/pages"
|
||||
)
|
||||
|
||||
type MastodonOAuthClient struct {
|
||||
Name string
|
||||
Id *string
|
||||
Secret *string
|
||||
RedirectUri *url.URL
|
||||
}
|
||||
|
||||
const MASTODON_TOKEN_COOKIE_NAME = "OAUTH-MASTODON"
|
||||
|
||||
type MastodonOAuthToken struct {
|
||||
InstanceUrl string `json:"instance_url"`
|
||||
Type string `json:"token_type"`
|
||||
Token string `json:"access_token"`
|
||||
Scope string `json:"scope"`
|
||||
CreatedAt int `json:"created_at"`
|
||||
}
|
||||
|
||||
func NewMastodonOAuthClient(redirect *url.URL) *MastodonOAuthClient {
|
||||
return &MastodonOAuthClient{
|
||||
Name: "project-extrovert-oauth-client",
|
||||
RedirectUri: redirect,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MastodonOAuthClient) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
errors.NewErrMethodNotAllowed("GET", "HEAD").ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
i := r.FormValue("instance-url")
|
||||
if i == "" {
|
||||
errors.NewErrMissingParams("instance-url").ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
iu, err := url.Parse(i)
|
||||
if err != nil {
|
||||
errors.
|
||||
NewErrBadRequest("\"instance-url\" is not a valid URL. Failed to parse due to %s", err.Error()).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
q := c.RedirectUri.Query()
|
||||
q.Del("code")
|
||||
q.Set("instance-url", iu.String())
|
||||
c.RedirectUri.RawQuery = q.Encode()
|
||||
|
||||
code := r.FormValue("code")
|
||||
|
||||
a := NewMastodonOAuthApp(iu)
|
||||
if err := a.PopulateWithRequestCookies(r); err != nil {
|
||||
// This step is necessary since after the user has been redirected back from Mastodon's
|
||||
// authorization endpoint, the browser will not send cookies. Adding this redirect step
|
||||
// forces it to make another request and send the cookies for the PopulateWithRequestCookies
|
||||
// function.
|
||||
if code != "" {
|
||||
q = c.RedirectUri.Query()
|
||||
q.Set("code", code)
|
||||
c.RedirectUri.RawQuery = q.Encode()
|
||||
|
||||
err = pages.RedirectPopUp(
|
||||
iu.Hostname()+" returned the OAuth code!",
|
||||
"The Mastodon instance "+iu.Hostname()+" returned the authorization code. "+
|
||||
"Click the button bellow to continue the log in process.",
|
||||
templ.SafeURL(c.RedirectUri.String()),
|
||||
).Render(r.Context(), w)
|
||||
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to render redirect page"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
err = a.PopulateWithInstance(c.Name, c.RedirectUri)
|
||||
if err != nil {
|
||||
errors.
|
||||
NewErrInternal(e.New("Unable to create application"), err).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ck, err := a.ToCookie()
|
||||
if err != nil {
|
||||
errors.
|
||||
NewErrInternal(e.New("Unable to make application cookie"), err).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
iu.Path = "/oauth/authorize"
|
||||
q = iu.Query()
|
||||
q.Set("client_id", a.ClientId)
|
||||
q.Set("redirect_uri", c.RedirectUri.String())
|
||||
q.Set("response_type", "code")
|
||||
q.Set("scope", "read write")
|
||||
q.Set("state", "state")
|
||||
q.Set("code_challenge", "challenge")
|
||||
q.Set("code_challenge_method", "plain")
|
||||
iu.RawQuery = q.Encode()
|
||||
|
||||
http.SetCookie(w, ck)
|
||||
err = pages.RedirectPopUp(
|
||||
"Create application to use in "+iu.Hostname()+"!",
|
||||
"An OAuth application was created in "+iu.Hostname()+" for you to use. "+
|
||||
"Click the button bellow to continue the log in process.",
|
||||
templ.SafeURL(iu.String()),
|
||||
).Render(r.Context(), w)
|
||||
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to render redirect page"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
errors.NewErrMissingParams("code").ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
iu.Path = "/oauth/token"
|
||||
q = iu.Query()
|
||||
q.Set("client_id", a.ClientId)
|
||||
q.Set("client_secret", a.ClientSecret)
|
||||
q.Set("code", code)
|
||||
q.Set("redirect_uri", c.RedirectUri.String())
|
||||
q.Set("scope", "read write")
|
||||
q.Set("grant_type", "authorization_code")
|
||||
q.Set("code_verifier", "challenge")
|
||||
q.Set("challenge_method", "plain")
|
||||
iu.RawQuery = q.Encode()
|
||||
|
||||
res, err := http.Post(iu.String(), "application/x-www-form-urlencoded", bytes.NewBuffer([]byte("")))
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Error while trying to request token"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
errors.
|
||||
NewErrInternal(e.New("Error while trying to read token response body"), err).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
errors.NewErrInternal(fmt.Errorf(
|
||||
"Unable to retrieve token, non-200 response from platform. \nStatus: %v\nBody: %s",
|
||||
res.StatusCode, string(body)),
|
||||
).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var t MastodonOAuthToken
|
||||
err = json.Unmarshal(body, &t)
|
||||
if err != nil {
|
||||
errors.
|
||||
NewErrInternal(e.New("Error while trying to validate token response body"), err).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
iu.Path = ""
|
||||
iu.RawQuery = ""
|
||||
t.InstanceUrl = iu.String()
|
||||
|
||||
cv, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
errors.
|
||||
NewErrInternal(e.New("Error while trying to validate token response body"), err).
|
||||
ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "__Host-" + MASTODON_TOKEN_COOKIE_NAME,
|
||||
Value: url.PathEscape(string(cv)),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
err = pages.RedirectPopUp(
|
||||
"Logged into "+iu.Hostname()+"!",
|
||||
"Your "+iu.Hostname()+" account was successfully logged into project-extrovert. "+
|
||||
"Click the button below to return to the Homepage.",
|
||||
templ.SafeURL("/"),
|
||||
).Render(r.Context(), w)
|
||||
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to render redirect page"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
type MastodonOAuthApp struct {
|
||||
InstanceUrl string `json:"instance_url"`
|
||||
ClientId string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
func NewMastodonOAuthApp(instance *url.URL) MastodonOAuthApp {
|
||||
return MastodonOAuthApp{InstanceUrl: instance.String()}
|
||||
}
|
||||
|
||||
func (a *MastodonOAuthApp) PopulateWithInstance(name string, redirect *url.URL) error {
|
||||
i, err := url.Parse(a.InstanceUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.Path = "/api/v1/apps"
|
||||
q := i.Query()
|
||||
q.Set("client_name", name)
|
||||
q.Set("redirect_uris", redirect.String())
|
||||
q.Set("scopes", "read write")
|
||||
i.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.Post(i.String(), "application/x-www-form-urlencoded", bytes.NewBuffer([]byte("")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if req.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf(
|
||||
"Unable to create application, instance %s returned a non-200 status code.\nStatus: %v\nBody: %s",
|
||||
i.Hostname(), req.StatusCode, string(body),
|
||||
)
|
||||
}
|
||||
|
||||
var ra MastodonOAuthApp
|
||||
err = json.Unmarshal(body, &ra)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.ClientId = ra.ClientId
|
||||
a.ClientSecret = ra.ClientSecret
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *MastodonOAuthApp) PopulateWithRequestCookies(r *http.Request) error {
|
||||
n := a.CookieName()
|
||||
c, err := r.Cookie(n)
|
||||
if err != nil {
|
||||
return e.Join(
|
||||
fmt.Errorf("Unable to find application cookie for instance %s", a.InstanceUrl),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
v, err := url.PathUnescape(c.Value)
|
||||
if err != nil {
|
||||
return e.Join(
|
||||
fmt.Errorf("Unable to unescape application cookie %s, value %v", n, c.Value),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var ca MastodonOAuthApp
|
||||
err = json.Unmarshal([]byte(v), &ca)
|
||||
if err != nil {
|
||||
return e.Join(
|
||||
fmt.Errorf("Unable to unmarshal application cookie %s, value %v", n, v),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
a.ClientId = ca.ClientId
|
||||
a.ClientSecret = ca.ClientSecret
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *MastodonOAuthApp) CookieName() string {
|
||||
u, _ := url.Parse(a.InstanceUrl)
|
||||
return "__Host-APP-MASTODON-" + strings.ToUpper(u.Hostname())
|
||||
}
|
||||
|
||||
func (a *MastodonOAuthApp) ToCookie() (*http.Cookie, error) {
|
||||
v, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return &http.Cookie{}, err
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: a.CookieName(),
|
||||
Value: url.PathEscape(string(v)),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
templ (c MastodonOAuthClient) LoginButton() {
|
||||
<button popovertargetaction="show" popovertarget="mastodon-login">Login on Mastodon</button>
|
||||
<div id="mastodon-login" popover>
|
||||
<dialog open>
|
||||
<article>
|
||||
<header>
|
||||
<button
|
||||
popovertargetaction="hide"
|
||||
popovertarget="mastodon-login"
|
||||
aria-label="Close"
|
||||
rel="prev"
|
||||
></button>
|
||||
<label for="instance-url">Choose a instance</label>
|
||||
</header>
|
||||
<form
|
||||
autocomplete="on"
|
||||
action={ templ.SafeURL(c.RedirectUri.String()) }
|
||||
enctype="application/x-www-form-urlencoded"
|
||||
>
|
||||
<fieldset role="group">
|
||||
<input
|
||||
type="url"
|
||||
name="instance-url"
|
||||
placeholder="https://mastodon.social"
|
||||
aria-label="Instance Url"
|
||||
list="instance-suggestions"
|
||||
required
|
||||
/>
|
||||
<datalist id="instance-suggestions">
|
||||
@components.InstancesOptions(20)
|
||||
</datalist>
|
||||
<button>Login</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
</div>
|
||||
}
|
||||
169
internals/oauth/oauth.templ
Normal file
169
internals/oauth/oauth.templ
Normal file
@@ -0,0 +1,169 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
e "errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"fmt"
|
||||
|
||||
"extrovert/internals/router/errors"
|
||||
"extrovert/templates/pages"
|
||||
)
|
||||
|
||||
type OAuthClient interface {
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
Token(r *http.Request) (string, error)
|
||||
LoginButton() templ.Component
|
||||
}
|
||||
|
||||
type DefaultOAuthToken struct {
|
||||
Type string `json:"token_type"`
|
||||
Token string `json:"access_token"`
|
||||
Expires int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
RefreshToken *string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type DefaultOAuthClient struct {
|
||||
Name string
|
||||
Id string
|
||||
Secret string
|
||||
AuthEndpoint *url.URL
|
||||
TokenEndpoint *url.URL
|
||||
RedirectUri *url.URL
|
||||
}
|
||||
|
||||
func NewDefaultOAuthClient(u *url.URL, id string, secret string, redirect *url.URL) DefaultOAuthClient {
|
||||
auth, _ := url.Parse(u.String())
|
||||
|
||||
q := auth.Query()
|
||||
q.Add("client_id", id)
|
||||
q.Add("redirect_uri", redirect.String())
|
||||
q.Add("response_type", "code")
|
||||
q.Add("scope", "read write")
|
||||
q.Add("state", "state")
|
||||
q.Add("code_challenge", "challenge")
|
||||
q.Add("code_challenge_method", "plain")
|
||||
auth.RawQuery = q.Encode()
|
||||
|
||||
auth = auth.JoinPath("/oauth2/authorize")
|
||||
|
||||
token, _ := url.Parse(u.String())
|
||||
token = token.JoinPath("/oauth2/token")
|
||||
|
||||
return DefaultOAuthClient{
|
||||
Name: u.Hostname(),
|
||||
Id: id,
|
||||
Secret: secret,
|
||||
AuthEndpoint: auth,
|
||||
TokenEndpoint: token,
|
||||
RedirectUri: redirect,
|
||||
}
|
||||
}
|
||||
|
||||
templ (c DefaultOAuthClient) LoginButton() {
|
||||
<a
|
||||
role="button"
|
||||
href={ templ.SafeURL(c.AuthEndpoint.String()) }
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
Login on { c.Name }
|
||||
</a>
|
||||
}
|
||||
|
||||
func (c DefaultOAuthClient) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
errors.NewErrMissingParams("code").ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
req := c.TokenEndpoint
|
||||
|
||||
q := req.Query()
|
||||
q.Add("client_id", c.Id)
|
||||
q.Add("client_secret", c.Secret)
|
||||
q.Add("code", code)
|
||||
q.Add("redirect_uri", c.RedirectUri.String())
|
||||
q.Add("grant_type", "authorization_code")
|
||||
q.Add("code_verifier", "challenge")
|
||||
q.Add("challenge_method", "plain")
|
||||
req.RawQuery = q.Encode()
|
||||
|
||||
res, err := http.Post(req.String(), "application/x-www-form-urlencoded", bytes.NewReader([]byte("")))
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Error while trying to request token"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Error while trying to read token response body"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
errors.NewErrInternal(fmt.Errorf(
|
||||
"Unable to retrieve token, non-200 response from platform. \nStatus: %v\nBody: %s",
|
||||
res.StatusCode, string(body)),
|
||||
).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var token DefaultOAuthToken
|
||||
err = json.Unmarshal(body, &token)
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Error while trying to validate token response body"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cv, err := json.Marshal(token)
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Error while trying to validate token response body"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "__Host-OAUTH-" + strings.ToUpper(c.Name),
|
||||
Value: url.PathEscape(string(cv)),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
err = pages.RedirectPopUp(
|
||||
"Logged into "+c.Name+"!",
|
||||
"Your "+c.Name+" account was successfully logged into project-extrovert. "+
|
||||
"Click the button below to return to the Homepage.",
|
||||
templ.SafeURL("/"),
|
||||
).Render(r.Context(), w)
|
||||
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to render redirect page"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (c DefaultOAuthClient) Token(r *http.Request) (string, error) {
|
||||
cookie, err := r.Cookie("__Host-OAUTH-" + strings.ToUpper(c.Name))
|
||||
if err != nil {
|
||||
return "", e.Join(e.New("Unable get token cookie"), err)
|
||||
}
|
||||
|
||||
j, err := url.PathUnescape(cookie.Value)
|
||||
if err != nil {
|
||||
return "", e.Join(e.New("Unable to unescape token json"), err)
|
||||
}
|
||||
|
||||
var token DefaultOAuthToken
|
||||
err = json.Unmarshal([]byte(j), &token)
|
||||
if err != nil {
|
||||
return "", e.Join(e.New("Unable to parse token json"), err)
|
||||
}
|
||||
|
||||
return token.Token, nil
|
||||
}
|
||||
23
internals/oauth/twitter.go
Normal file
23
internals/oauth/twitter.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package oauth
|
||||
|
||||
import "net/url"
|
||||
|
||||
type TwitterOAuth struct {
|
||||
DefaultOAuthClient
|
||||
}
|
||||
|
||||
func NewTwitterOAuth(id string, secret string, redirect *url.URL) TwitterOAuth {
|
||||
u, _ := url.Parse("https://api.twitter.com/2/")
|
||||
c := NewDefaultOAuthClient(u, id, secret, redirect)
|
||||
|
||||
c.Name = "Twitter"
|
||||
|
||||
u, _ = url.Parse("https://twitter.com/i/oauth2/authorize")
|
||||
q := c.AuthEndpoint.Query()
|
||||
q.Del("scope")
|
||||
q.Add("scope", "tweet.read tweet.write users.read")
|
||||
u.RawQuery = q.Encode()
|
||||
c.AuthEndpoint = u
|
||||
|
||||
return TwitterOAuth{c}
|
||||
}
|
||||
42
internals/router/errors/400.go
Normal file
42
internals/router/errors/400.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ErrBadRequest struct {
|
||||
Msg string `json:"message"`
|
||||
}
|
||||
|
||||
func NewErrBadRequest(format string, a ...string) RouteErrorHandler {
|
||||
return defaultErrorHandler{ErrBadRequest{Msg: fmt.Sprintf(format, a)}}
|
||||
}
|
||||
func (e ErrBadRequest) Error() string { return e.Msg }
|
||||
func (e ErrBadRequest) Status() int { return http.StatusBadRequest }
|
||||
|
||||
type ErrMissingParams struct {
|
||||
Params []string `json:"params"`
|
||||
}
|
||||
|
||||
func NewErrMissingParams(params ...string) RouteErrorHandler {
|
||||
return defaultErrorHandler{ErrMissingParams{Params: params}}
|
||||
}
|
||||
func (e ErrMissingParams) Error() string {
|
||||
return fmt.Sprintf("Missing parameters: %s.", strings.Join(e.Params, ", "))
|
||||
}
|
||||
func (e ErrMissingParams) Status() int { return http.StatusBadRequest }
|
||||
|
||||
type ErrMethodNotAllowed struct {
|
||||
Method string `json:"method"`
|
||||
Allowed []string `json:"allowed"`
|
||||
}
|
||||
|
||||
func NewErrMethodNotAllowed(method string, allowed ...string) RouteErrorHandler {
|
||||
return defaultErrorHandler{ErrMethodNotAllowed{Method: method, Allowed: allowed}}
|
||||
}
|
||||
func (e ErrMethodNotAllowed) Error() string {
|
||||
return fmt.Sprintf("Method %s not allowed. Allowed methods are: %s", e.Method, strings.Join(e.Allowed, ", "))
|
||||
}
|
||||
func (e ErrMethodNotAllowed) Status() int { return http.StatusMethodNotAllowed }
|
||||
16
internals/router/errors/500.go
Normal file
16
internals/router/errors/500.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ErrInternal struct {
|
||||
Err string `json:"error"`
|
||||
}
|
||||
|
||||
func NewErrInternal(err ...error) RouteErrorHandler {
|
||||
return defaultErrorHandler{ErrInternal{Err: errors.Join(err...).Error()}}
|
||||
}
|
||||
func (e ErrInternal) Error() string { return e.Err }
|
||||
func (e ErrInternal) Status() int { return http.StatusInternalServerError }
|
||||
87
internals/router/errors/errors.templ
Normal file
87
internals/router/errors/errors.templ
Normal file
@@ -0,0 +1,87 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"extrovert/templates/layouts"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type RouteError interface {
|
||||
Error() string
|
||||
Status() int
|
||||
}
|
||||
|
||||
type RouteErrorHandler interface {
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
Component() templ.Component
|
||||
JSON() string
|
||||
}
|
||||
|
||||
type defaultErrorHandler struct {
|
||||
RouteError
|
||||
}
|
||||
|
||||
func (e defaultErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.Header.Get("Accept"), "text/html") {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
err := e.Component().Render(r.Context(), w)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("Unable to render error message, using JSON representation: " + e.JSON()))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
_, err := w.Write([]byte(e.JSON()))
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("Unable to send error information due to: " + err.Error()))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(e.Status())
|
||||
}
|
||||
|
||||
func (e defaultErrorHandler) JSON() string {
|
||||
type jsonErr struct {
|
||||
Error string `json:"error"`
|
||||
Info any `json:"info"`
|
||||
}
|
||||
js, err := json.Marshal(jsonErr{
|
||||
Error: e.Error(),
|
||||
Info: e,
|
||||
})
|
||||
if err != nil {
|
||||
js, _ = json.Marshal(jsonErr{
|
||||
Error: "Unable to parse JSON of error",
|
||||
Info: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return string(js)
|
||||
}
|
||||
|
||||
templ (e defaultErrorHandler) Component() {
|
||||
@layouts.Page("Error") {
|
||||
<dialog open>
|
||||
<article>
|
||||
<header>
|
||||
<p>Error</p>
|
||||
</header>
|
||||
<p>
|
||||
{ e.Error() }
|
||||
</p>
|
||||
<footer>
|
||||
<a href={ templ.SafeURL("/") }>
|
||||
<button>Return to homepage</button>
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
}
|
||||
}
|
||||
47
internals/router/middleware.go
Normal file
47
internals/router/middleware.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Middleware interface {
|
||||
Serve(r http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
|
||||
type MiddlewaredResponse struct {
|
||||
w http.ResponseWriter
|
||||
status int
|
||||
bodyWrites [][]byte
|
||||
}
|
||||
|
||||
func NewMiddlewaredResponse(w http.ResponseWriter) *MiddlewaredResponse {
|
||||
return &MiddlewaredResponse{w, 200, [][]byte{[]byte("")}}
|
||||
}
|
||||
|
||||
func (m *MiddlewaredResponse) WriteHeader(s int) {
|
||||
log.Printf("Status changed %v", s)
|
||||
m.status = s
|
||||
}
|
||||
|
||||
func (m *MiddlewaredResponse) Header() http.Header {
|
||||
return m.w.Header()
|
||||
}
|
||||
|
||||
func (m *MiddlewaredResponse) Write(b []byte) (int, error) {
|
||||
m.bodyWrites = append(m.bodyWrites, b)
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (m *MiddlewaredResponse) ReallyWriteHeader() (int, error) {
|
||||
m.w.WriteHeader(m.status)
|
||||
bytes := 0
|
||||
for _, b := range m.bodyWrites {
|
||||
by, err := m.w.Write(b)
|
||||
bytes += by
|
||||
if err != nil {
|
||||
return bytes, err
|
||||
}
|
||||
}
|
||||
return bytes, nil
|
||||
}
|
||||
68
internals/router/router.go
Normal file
68
internals/router/router.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Route struct {
|
||||
Pattern string
|
||||
Handler http.Handler
|
||||
Children *[]Route
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
routes []Route
|
||||
middlewares []Middleware
|
||||
mux *http.ServeMux
|
||||
serveHTTP http.HandlerFunc
|
||||
}
|
||||
|
||||
func NewRouter(rs []Route) *Router {
|
||||
mux := http.NewServeMux()
|
||||
Router{}.registerAllRoutes("/", rs, mux)
|
||||
return &Router{rs, []Middleware{}, mux, mux.ServeHTTP}
|
||||
}
|
||||
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.serveHTTP(w, req)
|
||||
}
|
||||
|
||||
func (r *Router) AddMiddleware(m Middleware) {
|
||||
r.middlewares = append(r.middlewares, m)
|
||||
r.serveHTTP = r.wrapMiddleares(r.middlewares, r.serveHTTP)
|
||||
}
|
||||
|
||||
func (router Router) wrapMiddleares(ms []Middleware, h http.HandlerFunc) http.HandlerFunc {
|
||||
fh := h.ServeHTTP
|
||||
for _, m := range ms {
|
||||
fh = m.Serve(fh)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
mw := NewMiddlewaredResponse(w)
|
||||
fh(mw, r)
|
||||
_, err := mw.ReallyWriteHeader()
|
||||
if err != nil {
|
||||
_, _ = w.Write([]byte(fmt.Sprintf("Error while trying to write to body:\n%s", err.Error())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (router Router) registerAllRoutes(p string, rs []Route, mux *http.ServeMux) {
|
||||
for _, r := range rs {
|
||||
pattern := strings.Join([]string{
|
||||
strings.TrimSuffix(p, "/"),
|
||||
strings.TrimPrefix(r.Pattern, "/"),
|
||||
}, "/")
|
||||
log.Printf("registering route %s", pattern)
|
||||
|
||||
mux.Handle(pattern, r.Handler)
|
||||
|
||||
if r.Children != nil {
|
||||
router.registerAllRoutes(pattern, *r.Children, mux)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package internals
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
|
||||
const PERMISSIONS = 0755
|
||||
|
||||
type Page struct {
|
||||
Path string
|
||||
Component templ.Component
|
||||
}
|
||||
|
||||
type StaticWriter struct {
|
||||
DistDir *string
|
||||
StaticDir *string
|
||||
Pages []Page
|
||||
Context context.Context
|
||||
Logger log.Logger
|
||||
}
|
||||
|
||||
func (w *StaticWriter) WritePage(path string, writer func(ctx context.Context, w io.Writer) error) error {
|
||||
directory := filepath.Dir(path)
|
||||
err := os.MkdirAll(directory, PERMISSIONS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
err = writer(w.Context, f)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *StaticWriter) WriteAll() error {
|
||||
for _, page := range w.Pages {
|
||||
p := filepath.Join(*w.DistDir, page.Path)
|
||||
w.Logger.Printf("Writing page %s", p)
|
||||
err := w.WritePage(p, page.Component.Render)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(*w.StaticDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
} else if d.IsDir() || path == *w.StaticDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := filepath.Abs(*w.StaticDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = w.CopyStatic(strings.TrimPrefix(f, s))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *StaticWriter) CopyStatic(path string) error {
|
||||
c, err := os.ReadFile(filepath.Join(*w.StaticDir, path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p := filepath.Join(*w.DistDir, path)
|
||||
err = os.MkdirAll(filepath.Dir(p), PERMISSIONS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
b, err := f.Write(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Logger.Printf("Wrote %v bytes in %s", b, p)
|
||||
|
||||
return nil
|
||||
}
|
||||
26
main.go
26
main.go
@@ -5,18 +5,18 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"extrovert/internals"
|
||||
"extrovert/internals/middlewares"
|
||||
"extrovert/internals/router"
|
||||
"extrovert/routes"
|
||||
)
|
||||
|
||||
var logger = log.Default()
|
||||
|
||||
func main() {
|
||||
staticDir := flag.String("s", "./static", "the directory to copy static files from")
|
||||
port := flag.Int("p", 8080, "the port to run the server")
|
||||
dev := flag.Bool("d", false, "if the server is in development mode")
|
||||
cache := flag.Bool("c", true, "if the static files are cached")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
@@ -24,21 +24,13 @@ func main() {
|
||||
log.Printf("Running server in DEVELOPMENT MODE")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
r := router.NewRouter(routes.ROUTES)
|
||||
if *dev {
|
||||
r.AddMiddleware(middlewares.NewDevelopmentMiddleware(logger))
|
||||
}
|
||||
r.AddMiddleware(middlewares.NewCookiesCryptoMiddleware(os.Getenv("CRYPTO_COOKIE_KEY"), logger))
|
||||
|
||||
routes.RegisterAllRoutes(routes.ROUTES, mux)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
logger.Printf("Handling file server request. path=%s", r.URL.Path)
|
||||
http.FileServer(http.Dir(*staticDir)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
logger.Printf("Running server at port: %v", *port)
|
||||
|
||||
middleware := internals.NewMiddleware(mux, *dev, !*cache, log.Default())
|
||||
err := http.ListenAndServe(fmt.Sprintf(":%v", *port), middleware)
|
||||
err := http.ListenAndServe(fmt.Sprintf(":%v", *port), r)
|
||||
if err != nil {
|
||||
logger.Fatalf("Server crashed due to:\n%s", err)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"extrovert/layouts"
|
||||
"extrovert/templates/layouts"
|
||||
"extrovert/components"
|
||||
"extrovert/internals"
|
||||
"extrovert/internals/app"
|
||||
"net/http"
|
||||
"extrovert/internals/router/errors"
|
||||
e "errors"
|
||||
)
|
||||
|
||||
func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = internals.GetCookie("twitter-data", w, r)
|
||||
type Homepage struct{}
|
||||
|
||||
IndexPage().Render(context.TODO(), w)
|
||||
func (h Homepage) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err := h.page().Render(context.Background(), w)
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to render dashboard"), err).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
templ IndexPage() {
|
||||
templ (h Homepage) page() {
|
||||
@layouts.Page("Project Extrovert") {
|
||||
<div style="max-width:50rem;">
|
||||
<div style="display:flex;flex-direction:column;gap:1rem;">
|
||||
@@ -25,8 +31,8 @@ templ IndexPage() {
|
||||
style="height:100%;display:flex;gap:2rem;"
|
||||
>
|
||||
<div style="display:flex;flex-direction:column;gap:1rem;width:15rem;">
|
||||
@components.LoginTwitter()
|
||||
@components.LoginMastodon()
|
||||
@app.TWITTER_APP.LoginButton()
|
||||
@app.MASTODON_APP.LoginButton()
|
||||
</div>
|
||||
<fieldset>
|
||||
<textarea
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"extrovert/layouts"
|
||||
"extrovert/internals"
|
||||
"log"
|
||||
)
|
||||
|
||||
type MastodonTokenResponse struct {
|
||||
Type string `json:"token_type"`
|
||||
Token string `json:"access_token"`
|
||||
Expires int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
func MastodonLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
error := internals.HttpErrorHelper(w)
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
error(
|
||||
"Bad request",
|
||||
errors.New("Missing \"code\" parameter"),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
tReq := fmt.Sprintf("https://api.twitter.com/2/oauth2/token"+
|
||||
"?grant_type=authorization_code"+
|
||||
"&client_id=%s"+
|
||||
"&code_verifier=challenge"+
|
||||
"&code=%s"+
|
||||
"&challenge_method=plain"+
|
||||
"&redirect_uri=http://localhost:7331/api/oauth/twitter",
|
||||
os.Getenv("TWITTER_CLIENT_ID"),
|
||||
code,
|
||||
)
|
||||
|
||||
t, err := http.Post(tReq, "application/x-www-form-urlencoded", bytes.NewReader([]byte("")))
|
||||
if error("Error trying to request token from twitter", err, http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(t.Body)
|
||||
if error("Error trying to read response body from twitter", err, http.StatusInternalServerError) {
|
||||
return
|
||||
} else if t.StatusCode < 200 || t.StatusCode > 299 {
|
||||
error(
|
||||
"Error trying to request token from twitter, returned non-200 code",
|
||||
errors.New(fmt.Sprintf("Code: %v, Return value: %s", t.StatusCode, string(b))),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var res TwitterTokenResponse
|
||||
err = json.Unmarshal(b, &res)
|
||||
if error("Error trying to parse response body from twitter", err, http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Print(res)
|
||||
|
||||
c := internals.GetCookie("twitter-data", w, r)
|
||||
c.Value = res.Token
|
||||
http.SetCookie(w, c)
|
||||
|
||||
MastodonLogin().Render(context.Background(), w)
|
||||
}
|
||||
|
||||
templ MastodonLogin() {
|
||||
@layouts.Page("Project Extrovert") {
|
||||
<dialog open>
|
||||
<article>
|
||||
<header>
|
||||
<p>Logged into Twitter!</p>
|
||||
</header>
|
||||
<p>
|
||||
Your account was succefully connected with project-extrovert!
|
||||
Click "Ok" to return to the index page.
|
||||
</p>
|
||||
<footer>
|
||||
<a href="/index.html">
|
||||
<button>Ok</button>
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
@IndexPage()
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,60 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
e "errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"extrovert/internals"
|
||||
"extrovert/internals/router/errors"
|
||||
)
|
||||
|
||||
func AiTxt() templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
|
||||
aiList, err := http.Get("https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/ai.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type AITxt struct{}
|
||||
|
||||
bytes, err := io.ReadAll(aiList.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(w, string(bytes))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func AiTxtHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (_ AITxt) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Cache-Control", "max-age=604800, stale-while-revalidate=86400, stale-if-error=86400")
|
||||
w.Header().Add("CDN-Cache-Control", "max-age=604800")
|
||||
|
||||
error := internals.HttpErrorHelper(w)
|
||||
err := AiTxt().Render(context.Background(), w)
|
||||
if error("Error trying to create ai block list", err, http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
}
|
||||
|
||||
func RobotsTxt() templ.Component {
|
||||
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
|
||||
aiList, err := http.Get("https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bytes, err := io.ReadAll(aiList.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(w, string(bytes))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func RobotsTxtHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Cache-Control", "max-age=604800, stale-while-revalidate=86400, stale-if-error=86400")
|
||||
w.Header().Add("CDN-Cache-Control", "max-age=604800")
|
||||
|
||||
error := internals.HttpErrorHelper(w)
|
||||
err := RobotsTxt().Render(context.Background(), w)
|
||||
if error("Error trying to create robots block list", err, http.StatusInternalServerError) {
|
||||
list, err := http.Get("https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/ai.txt")
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to fetch ai.txt list"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
bytes, err := io.ReadAll(list.Body)
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to read dynamic ai.txt list"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, err = w.Write(bytes)
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to write ai.txt list"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type RobotsTxt struct{}
|
||||
|
||||
func (_ RobotsTxt) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Cache-Control", "max-age=604800, stale-while-revalidate=86400, stale-if-error=86400")
|
||||
w.Header().Add("CDN-Cache-Control", "max-age=604800")
|
||||
|
||||
list, err := http.Get("https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.txt")
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to fetch robots.txt list"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
bytes, err := io.ReadAll(list.Body)
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to read dynamic robots.txt list"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.WriteString(w, string(bytes))
|
||||
if err != nil {
|
||||
errors.NewErrInternal(e.New("Unable to write robots.txt list"), err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
|
||||
var ROUTES = []Route{
|
||||
{
|
||||
Pattern: "/index.html",
|
||||
Static: true,
|
||||
Page: IndexPage(),
|
||||
Handler: IndexHandler,
|
||||
},
|
||||
{
|
||||
Pattern: "/api/twitter/oauth",
|
||||
Static: false,
|
||||
Page: TwitterOAuth(),
|
||||
Handler: TwitterOAuthHandler,
|
||||
},
|
||||
{
|
||||
Pattern: "/robots.txt",
|
||||
Static: true,
|
||||
Page: RobotsTxt(),
|
||||
Handler: RobotsTxtHandler,
|
||||
},
|
||||
{
|
||||
Pattern: "/ai.txt",
|
||||
Static: true,
|
||||
Page: AiTxt(),
|
||||
Handler: AiTxtHandler,
|
||||
},
|
||||
}
|
||||
|
||||
type RouteHandler = func(http.ResponseWriter, *http.Request)
|
||||
|
||||
type Route struct {
|
||||
Pattern string
|
||||
Static bool
|
||||
Handler RouteHandler
|
||||
Page templ.Component
|
||||
}
|
||||
|
||||
func RegisterAllRoutes(routes []Route, s *http.ServeMux) {
|
||||
for _, r := range routes {
|
||||
s.HandleFunc(r.Pattern, r.Handler)
|
||||
}
|
||||
}
|
||||
14
routes/routes.go
Normal file
14
routes/routes.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"extrovert/internals/app"
|
||||
"extrovert/internals/router"
|
||||
)
|
||||
|
||||
var ROUTES = []router.Route{
|
||||
{Pattern: "/{$}", Handler: Homepage{}},
|
||||
{Pattern: "/robots.txt", Handler: RobotsTxt{}},
|
||||
{Pattern: "/ai.txt", Handler: AITxt{}},
|
||||
{Pattern: app.TWITTER_REDIRECT, Handler: app.TWITTER_APP},
|
||||
{Pattern: app.MASTODON_REDIRECT, Handler: app.MASTODON_APP},
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"extrovert/layouts"
|
||||
"extrovert/internals"
|
||||
)
|
||||
|
||||
type TwitterTokenResponse struct {
|
||||
Type string `json:"token_type"`
|
||||
Token string `json:"access_token"`
|
||||
Expires int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
func TwitterOAuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
error := internals.HttpErrorHelper(w)
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
error(
|
||||
"Bad request",
|
||||
errors.New("Missing \"code\" parameter"),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
tReq := fmt.Sprintf("https://api.twitter.com/2/oauth2/token"+
|
||||
"?grant_type=authorization_code"+
|
||||
"&client_id=%s"+
|
||||
"&code_verifier=challenge"+
|
||||
"&code=%s"+
|
||||
"&challenge_method=plain"+
|
||||
"&redirect_uri=http://localhost:7331/api/oauth/twitter",
|
||||
os.Getenv("TWITTER_CLIENT_ID"),
|
||||
code,
|
||||
)
|
||||
|
||||
t, err := http.Post(tReq, "application/x-www-form-urlencoded", bytes.NewReader([]byte("")))
|
||||
if error("Error trying to request token from twitter", err, http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(t.Body)
|
||||
if error("Error trying to read response body from twitter", err, http.StatusInternalServerError) {
|
||||
return
|
||||
} else if t.StatusCode < 200 || t.StatusCode > 299 {
|
||||
error(
|
||||
"Error trying to request token from twitter, returned non-200 code",
|
||||
errors.New(fmt.Sprintf("Code: %v, Return value: %s", t.StatusCode, string(b))),
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var res TwitterTokenResponse
|
||||
err = json.Unmarshal(b, &res)
|
||||
if error("Error trying to parse response body from twitter", err, http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
|
||||
c := internals.GetCookie("twitter-data", w, r)
|
||||
c.Value = res.Token
|
||||
http.SetCookie(w, c)
|
||||
|
||||
TwitterOAuth().Render(context.Background(), w)
|
||||
}
|
||||
|
||||
templ TwitterOAuth() {
|
||||
@layouts.Page("Project Extrovert") {
|
||||
<dialog open>
|
||||
<article>
|
||||
<header>
|
||||
<p>Logged into Twitter!</p>
|
||||
</header>
|
||||
<p>
|
||||
Your account was succefully connected with project-extrovert!
|
||||
Click "Ok" to return to the index page.
|
||||
</p>
|
||||
<footer>
|
||||
<a href="/index.html">
|
||||
<button>Ok</button>
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
@IndexPage()
|
||||
}
|
||||
}
|
||||
26
templates/pages/redirect.templ
Normal file
26
templates/pages/redirect.templ
Normal file
@@ -0,0 +1,26 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"extrovert/templates/layouts"
|
||||
)
|
||||
|
||||
templ RedirectPopUp(title string, msg string, url templ.SafeURL) {
|
||||
@layouts.Page("Project Extrovert") {
|
||||
<dialog open>
|
||||
<article>
|
||||
<header>
|
||||
<p>{ title }</p>
|
||||
</header>
|
||||
<p>
|
||||
{ msg }
|
||||
</p>
|
||||
<footer>
|
||||
<a href={ url }>
|
||||
<button>Ok</button>
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
{ children... }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user