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_ID=**********************************
|
||||||
TWITTER_CLIENT_SECRET=**************************************************
|
TWITTER_CLIENT_SECRET=**************************************************
|
||||||
INSTANCES_SOCIAL_TOKEN=********************************************************************************************************************************
|
INSTANCES_SOCIAL_TOKEN=********************************************************************************************************************************
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"slices"
|
"slices"
|
||||||
"extrovert/internals"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"errors"
|
"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())
|
log.Printf("WARN: Unable to fetch Mastodon instance datalist due to:\n%s\n\nFall backing into static list.", err.Error())
|
||||||
return INSTANCES
|
return INSTANCES
|
||||||
}
|
}
|
||||||
return internals.RemoveDuplicates(slices.Concat(INSTANCES, i))
|
return slices.Concat(INSTANCES, i)
|
||||||
}
|
}
|
||||||
|
|
||||||
templ InstancesOptions(limit int) {
|
templ InstancesOptions(limit int) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ var loginUrl = fmt.Sprintf("https://x.com/i/oauth2/authorize"+
|
|||||||
"&code_challenge=challenge"+
|
"&code_challenge=challenge"+
|
||||||
"&code_challenge_method=plain",
|
"&code_challenge_method=plain",
|
||||||
os.Getenv("TWITTER_CLIENT_ID"),
|
os.Getenv("TWITTER_CLIENT_ID"),
|
||||||
url.PathEscape("http://localhost:7331/api/oauth/twitter"),
|
url.PathEscape("http://localhost:7331/api/twitter/oauth"),
|
||||||
)
|
)
|
||||||
|
|
||||||
templ LoginTwitter() {
|
templ LoginTwitter() {
|
||||||
@@ -36,16 +36,27 @@ templ LoginMastodon() {
|
|||||||
></button>
|
></button>
|
||||||
<label for="instance-url">Choose a instance</label>
|
<label for="instance-url">Choose a instance</label>
|
||||||
</header>
|
</header>
|
||||||
|
<form
|
||||||
|
autocomplete="on"
|
||||||
|
method="post"
|
||||||
|
action="/api/mastodon/apps"
|
||||||
|
enctype="application/x-www-form-urlencoded"
|
||||||
|
>
|
||||||
|
<fieldset role="group">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
name="instance-url"
|
name="instance-url"
|
||||||
placeholder="Instance Url"
|
placeholder="https://mastodon.social"
|
||||||
aria-label="Instance Url"
|
aria-label="Instance Url"
|
||||||
list="instance-suggestions"
|
list="instance-suggestions"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<datalist id="instance-suggestions">
|
<datalist id="instance-suggestions">
|
||||||
@InstancesOptions(20)
|
@InstancesOptions(20)
|
||||||
</datalist>
|
</datalist>
|
||||||
|
<button>Login</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
</article>
|
</article>
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</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
|
|
||||||
}
|
|
||||||
24
main.go
24
main.go
@@ -5,18 +5,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
"extrovert/internals"
|
"extrovert/internals/middlewares"
|
||||||
|
"extrovert/internals/router"
|
||||||
"extrovert/routes"
|
"extrovert/routes"
|
||||||
)
|
)
|
||||||
|
|
||||||
var logger = log.Default()
|
var logger = log.Default()
|
||||||
|
|
||||||
func main() {
|
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")
|
port := flag.Int("p", 8080, "the port to run the server")
|
||||||
dev := flag.Bool("d", false, "if the server is in development mode")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -24,21 +24,13 @@ func main() {
|
|||||||
log.Printf("Running server in DEVELOPMENT MODE")
|
log.Printf("Running server in DEVELOPMENT MODE")
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
r := router.NewRouter(routes.ROUTES)
|
||||||
|
if *dev {
|
||||||
routes.RegisterAllRoutes(routes.ROUTES, mux)
|
r.AddMiddleware(middlewares.NewDevelopmentMiddleware(logger))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
})
|
r.AddMiddleware(middlewares.NewCookiesCryptoMiddleware(os.Getenv("CRYPTO_COOKIE_KEY"), logger))
|
||||||
|
|
||||||
logger.Printf("Running server at port: %v", *port)
|
err := http.ListenAndServe(fmt.Sprintf(":%v", *port), r)
|
||||||
|
|
||||||
middleware := internals.NewMiddleware(mux, *dev, !*cache, log.Default())
|
|
||||||
err := http.ListenAndServe(fmt.Sprintf(":%v", *port), middleware)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("Server crashed due to:\n%s", err)
|
logger.Fatalf("Server crashed due to:\n%s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"extrovert/templates/layouts"
|
||||||
|
|
||||||
"extrovert/layouts"
|
|
||||||
"extrovert/components"
|
"extrovert/components"
|
||||||
"extrovert/internals"
|
"extrovert/internals/app"
|
||||||
|
"net/http"
|
||||||
|
"extrovert/internals/router/errors"
|
||||||
|
e "errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
type Homepage struct{}
|
||||||
_ = internals.GetCookie("twitter-data", w, r)
|
|
||||||
|
|
||||||
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") {
|
@layouts.Page("Project Extrovert") {
|
||||||
<div style="max-width:50rem;">
|
<div style="max-width:50rem;">
|
||||||
<div style="display:flex;flex-direction:column;gap:1rem;">
|
<div style="display:flex;flex-direction:column;gap:1rem;">
|
||||||
@@ -25,8 +31,8 @@ templ IndexPage() {
|
|||||||
style="height:100%;display:flex;gap:2rem;"
|
style="height:100%;display:flex;gap:2rem;"
|
||||||
>
|
>
|
||||||
<div style="display:flex;flex-direction:column;gap:1rem;width:15rem;">
|
<div style="display:flex;flex-direction:column;gap:1rem;width:15rem;">
|
||||||
@components.LoginTwitter()
|
@app.TWITTER_APP.LoginButton()
|
||||||
@components.LoginMastodon()
|
@app.MASTODON_APP.LoginButton()
|
||||||
</div>
|
</div>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<textarea
|
<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
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
e "errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"extrovert/internals/router/errors"
|
||||||
|
|
||||||
"extrovert/internals"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func AiTxt() templ.Component {
|
type AITxt struct{}
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
bytes, err := io.ReadAll(aiList.Body)
|
func (_ AITxt) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = io.WriteString(w, string(bytes))
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func AiTxtHandler(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("Cache-Control", "max-age=604800, stale-while-revalidate=86400, stale-if-error=86400")
|
||||||
w.Header().Add("CDN-Cache-Control", "max-age=604800")
|
w.Header().Add("CDN-Cache-Control", "max-age=604800")
|
||||||
|
|
||||||
error := internals.HttpErrorHelper(w)
|
list, err := http.Get("https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/ai.txt")
|
||||||
err := AiTxt().Render(context.Background(), w)
|
if err != nil {
|
||||||
if error("Error trying to create ai block list", err, http.StatusInternalServerError) {
|
errors.NewErrInternal(e.New("Unable to fetch ai.txt list"), err).ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Add("Content-Type", "text/plain")
|
|
||||||
}
|
bytes, err := io.ReadAll(list.Body)
|
||||||
|
if err != nil {
|
||||||
func RobotsTxt() templ.Component {
|
errors.NewErrInternal(e.New("Unable to read dynamic ai.txt list"), err).ServeHTTP(w, r)
|
||||||
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
|
return
|
||||||
aiList, err := http.Get("https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/robots.txt")
|
}
|
||||||
if err != nil {
|
|
||||||
return err
|
w.Header().Add("Content-Type", "text/plain")
|
||||||
}
|
_, err = w.Write(bytes)
|
||||||
|
if err != nil {
|
||||||
bytes, err := io.ReadAll(aiList.Body)
|
errors.NewErrInternal(e.New("Unable to write ai.txt list"), err).ServeHTTP(w, r)
|
||||||
if err != nil {
|
return
|
||||||
return err
|
}
|
||||||
}
|
}
|
||||||
_, err = io.WriteString(w, string(bytes))
|
|
||||||
return err
|
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")
|
||||||
func RobotsTxtHandler(w http.ResponseWriter, r *http.Request) {
|
w.Header().Add("CDN-Cache-Control", "max-age=604800")
|
||||||
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 {
|
||||||
error := internals.HttpErrorHelper(w)
|
errors.NewErrInternal(e.New("Unable to fetch robots.txt list"), err).ServeHTTP(w, r)
|
||||||
err := RobotsTxt().Render(context.Background(), w)
|
return
|
||||||
if error("Error trying to create robots block list", err, http.StatusInternalServerError) {
|
}
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
w.Header().Add("Content-Type", "text/plain")
|
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