23 Commits

Author SHA1 Message Date
Gustavo "Guz" L. de Mello
e41944cd82 feat(oauth,mastodon): mastodon oauth enpoint 2024-07-31 15:52:46 -03:00
Gustavo "Guz" L. de Mello
64f15565fb fix(errors): errors handling interface implementation 2024-07-30 11:51:55 -03:00
Gustavo "Guz" L. de Mello
bd677c9613 feat(oauth,mastodon): mastodon login first draft 2024-07-30 11:51:31 -03:00
Gustavo "Guz" L. de Mello
24125aa767 feat(oauth): token getter method 2024-07-24 19:15:02 -03:00
Gustavo "Guz" L. de Mello
ae3dae1ffa fix(oauth): escape json string so it can be set by http package 2024-07-24 19:08:18 -03:00
Gustavo "Guz" L. de Mello
cd2f58a24c feat(oauth,twitter): default oauth2 implementation 2024-07-24 18:43:23 -03:00
Gustavo "Guz" L. de Mello
e663abe150 fix: remove old code 2024-07-24 18:42:41 -03:00
Gustavo "Guz" L. de Mello
518d04fa72 chore: remove old unused code 2024-07-24 15:23:30 -03:00
Gustavo "Guz" L. de Mello
046e0f9259 refactor(errors): new error "helpers", following a more golang way 2024-07-24 15:23:07 -03:00
Gustavo "Guz" L. de Mello
2b5366d407 test: update test code 2024-07-23 15:03:16 -03:00
Gustavo "Guz" L. de Mello
dbfb7e547d feat(cookies,middleware): constructor functions and encryption error handling 2024-07-23 14:29:05 -03:00
Gustavo "Guz" L. de Mello
c3dc549ceb feat(cookies): encrypt and decrypt cookies sent to the client
This is modtly (to try) preventing malicious client-side code, like
browser extensions, from reading social media tokens easily. Since this
application doesn't have a database, this is the best that can be done.
2024-07-23 13:40:30 -03:00
Gustavo "Guz" L. de Mello
eb52dd73d1 feat(router): check error of middlewared response writer 2024-07-22 12:25:12 -03:00
Gustavo "Guz" L. de Mello
9dd4681857 refactor(middlewares,router): follow http package structure and interfaces 2024-07-22 11:24:14 -03:00
Gustavo "Guz" L. de Mello
294b943353 feat(cookies,encoding): panic handling 2024-07-14 13:40:13 -03:00
Gustavo "Guz" L. de Mello
63d71cd625 refactor(cookies,encoding): move loop to it's dedicated function 2024-07-14 13:39:14 -03:00
Gustavo "Guz" L. de Mello
8dcc720e9f refactor(cookies,encoding): move string encoding and decoding to dedicated functions 2024-07-14 12:26:57 -03:00
Gustavo "Guz" L. de Mello
e2518662c9 feat(cookies,encoding): decoding of number types and booleans 2024-07-14 12:15:25 -03:00
Gustavo "Guz" L. de Mello
6e7d8aeedb test(cookies,encoding): refactor test to better readability and modifing 2024-07-14 12:14:43 -03:00
Gustavo "Guz" L. de Mello
bc05eab477 feat: cookie encoding and decoding 2024-07-13 17:59:22 -03:00
Gustavo "Guz" L. de Mello
8a4d9dde1d feat: oauth client 2024-07-13 17:58:55 -03:00
Gustavo "Guz" L. de Mello
7ab8527a44 refactor: change project and templates layout 2024-07-13 17:58:03 -03:00
Gustavo "Guz" L. de Mello
10eaaf4d5f feat: basic oauth handling, THIS IS BROKEN 2024-07-10 23:34:37 -03:00
30 changed files with 1549 additions and 533 deletions

View File

@@ -1,3 +1,4 @@
CRYPTO_COOKIE_KEY=****************
TWITTER_CLIENT_ID=**********************************
TWITTER_CLIENT_SECRET=**************************************************
INSTANCES_SOCIAL_TOKEN=********************************************************************************************************************************

View File

@@ -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) {

View File

@@ -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>
<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="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
View 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
}()

View File

@@ -1,4 +0,0 @@
package internals
const APP_VERSION = "1"
const APP_NAME = "project-extrovert"

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -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
View File

@@ -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()
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
r := router.NewRouter(routes.ROUTES)
if *dev {
r.AddMiddleware(middlewares.NewDevelopmentMiddleware(logger))
}
})
r.AddMiddleware(middlewares.NewCookiesCryptoMiddleware(os.Getenv("CRYPTO_COOKIE_KEY"), logger))
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)
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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")

View File

@@ -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
View 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},
}

View File

@@ -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()
}
}

View 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... }
}
}