summaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
authorElizabeth Hunt <elizabeth.hunt@simponic.xyz>2025-01-03 01:47:07 -0800
committerElizabeth Hunt <elizabeth.hunt@simponic.xyz>2025-01-03 01:47:07 -0800
commitf163a242792cd325c9414587d52f3d8584f28df1 (patch)
treeb57ad121cc3f4ffc2bc55f4d63bfaaf6026dd239 /api
downloadphoneof-f163a242792cd325c9414587d52f3d8584f28df1.tar.gz
phoneof-f163a242792cd325c9414587d52f3d8584f28df1.zip
initial commit
Diffstat (limited to 'api')
-rw-r--r--api/api.go121
-rw-r--r--api/api_test.go89
-rw-r--r--api/chat/chat.go195
-rw-r--r--api/template/template.go73
-rw-r--r--api/types/types.go28
5 files changed, 506 insertions, 0 deletions
diff --git a/api/api.go b/api/api.go
new file mode 100644
index 0000000..a887604
--- /dev/null
+++ b/api/api.go
@@ -0,0 +1,121 @@
+package api
+
+import (
+ "database/sql"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "time"
+
+ "git.simponic.xyz/simponic/phoneof/adapters/messaging"
+ "git.simponic.xyz/simponic/phoneof/api/chat"
+ "git.simponic.xyz/simponic/phoneof/api/template"
+ "git.simponic.xyz/simponic/phoneof/api/types"
+ "git.simponic.xyz/simponic/phoneof/args"
+ "git.simponic.xyz/simponic/phoneof/utils"
+)
+
+func LogRequestContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ context.Start = time.Now().UTC()
+ context.Id = utils.RandomId()
+
+ log.Println(req.Method, req.URL.Path, req.RemoteAddr, context.Id)
+ return success(context, req, resp)
+ }
+}
+
+func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ end := time.Now().UTC()
+ log.Println(context.Id, "took", end.Sub(context.Start))
+
+ return success(context, req, resp)
+ }
+}
+
+func HealthCheckContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ resp.WriteHeader(200)
+ resp.Write([]byte("healthy"))
+ return success(context, req, resp)
+ }
+}
+
+func FailurePassingContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(_success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ return failure(context, req, resp)
+ }
+}
+
+func IdContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ return success(context, req, resp)
+ }
+}
+
+func CacheControlMiddleware(next http.Handler, maxAge int) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ header := fmt.Sprintf("public, max-age=%d", maxAge)
+ w.Header().Set("Cache-Control", header)
+ next.ServeHTTP(w, r)
+ })
+}
+
+func MakeMux(argv *args.Arguments, dbConn *sql.DB) *http.ServeMux {
+ mux := http.NewServeMux()
+
+ staticFileServer := http.FileServer(http.Dir(argv.StaticPath))
+ mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(staticFileServer, 3600)))
+
+ makeRequestContext := func() *types.RequestContext {
+ return &types.RequestContext{
+ DBConn: dbConn,
+ Args: argv,
+ TemplateData: &map[string]interface{}{},
+ }
+ }
+
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+ LogRequestContinuation(requestContext, r, w)(HealthCheckContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+
+ templateFile := "home.html"
+ LogRequestContinuation(requestContext, r, w)(template.TemplateContinuation(templateFile, true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /chat", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+ LogRequestContinuation(requestContext, r, w)(chat.ValidateFren, FailurePassingContinuation)(template.TemplateContinuation("chat.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /chat/messages", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+ LogRequestContinuation(requestContext, r, w)(chat.ValidateFren, FailurePassingContinuation)(chat.FetchMessagesContinuation, FailurePassingContinuation)(template.TemplateContinuation("messages.html", false), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ messageHandler := messaging.HttpSmsMessagingAdapter{
+ ApiToken: os.Getenv("HTTPSMS_API_TOKEN"),
+ FromPhoneNumber: os.Getenv("FROM_PHONE_NUMBER"),
+ ToPhoneNumber: os.Getenv("TO_PHONE_NUMBER"),
+ Endpoint: argv.HttpSmsEndpoint,
+ }
+ sendMessageContinuation := chat.SendMessageContinuation(&messageHandler)
+ mux.HandleFunc("POST /chat", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+ LogRequestContinuation(requestContext, r, w)(chat.ValidateFren, FailurePassingContinuation)(sendMessageContinuation, FailurePassingContinuation)(template.TemplateContinuation("chat.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ smsEventProcessor := chat.ChatEventProcessorContinuation(os.Getenv("HTTPSMS_SIGNING_KEY"))
+ mux.HandleFunc("POST /chat/event", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+ LogRequestContinuation(requestContext, r, w)(smsEventProcessor, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ return mux
+}
diff --git a/api/api_test.go b/api/api_test.go
new file mode 100644
index 0000000..f737413
--- /dev/null
+++ b/api/api_test.go
@@ -0,0 +1,89 @@
+package api_test
+
+import (
+ "database/sql"
+ "io"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "git.simponic.xyz/simponic/phoneof/api"
+ "git.simponic.xyz/simponic/phoneof/args"
+ "git.simponic.xyz/simponic/phoneof/database"
+ "git.simponic.xyz/simponic/phoneof/utils"
+)
+
+func setup(t *testing.T) (*sql.DB, *httptest.Server) {
+ randomDb := utils.RandomId()
+
+ testDb := database.MakeConn(&randomDb)
+ database.Migrate(testDb)
+
+ arguments := &args.Arguments{
+ TemplatePath: "../templates",
+ StaticPath: "../static",
+ }
+
+ mux := api.MakeMux(arguments, testDb)
+ testServer := httptest.NewServer(mux)
+
+ t.Cleanup(func() {
+ testServer.Close()
+ testDb.Close()
+ os.Remove(randomDb)
+ })
+ return testDb, testServer
+}
+
+func assertResponseCode(t *testing.T, resp *httptest.ResponseRecorder, statusCode int) {
+ if resp.Code != statusCode {
+ t.Errorf("code is unexpected: %d, expected %d", resp.Code, statusCode)
+ }
+}
+
+func assertResponseBody(t *testing.T, resp *httptest.ResponseRecorder, body string) {
+ buf := new(strings.Builder)
+ _, err := io.Copy(buf, resp.Body)
+ if err != nil {
+ panic("could not read response body")
+ }
+ bodyStr := buf.String()
+ if bodyStr != body {
+ t.Errorf("body is unexpected: %s, expected %s", bodyStr, body)
+ }
+}
+
+func TestHealthcheck(t *testing.T) {
+ _, testServer := setup(t)
+
+ req := httptest.NewRequest("GET", "/health", nil)
+ resp := httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(resp, req)
+
+ assertResponseCode(t, resp, 200)
+ assertResponseBody(t, resp, "healthy")
+}
+
+func TestHello(t *testing.T) {
+ _, testServer := setup(t)
+
+ req := httptest.NewRequest("GET", "/", nil)
+ resp := httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(resp, req)
+
+ assertResponseCode(t, resp, 200)
+}
+
+func TestCachingStaticFiles(t *testing.T) {
+ _, testServer := setup(t)
+
+ req := httptest.NewRequest("GET", "/static/css/styles.css", nil)
+ resp := httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(resp, req)
+
+ assertResponseCode(t, resp, 200)
+ if resp.Header().Get("Cache-Control") != "public, max-age=3600" {
+ t.Errorf("client cache will live indefinitely for static files, which is probably not great! %s", resp.Header().Get("Cache-Control"))
+ }
+}
diff --git a/api/chat/chat.go b/api/chat/chat.go
new file mode 100644
index 0000000..51ee47d
--- /dev/null
+++ b/api/chat/chat.go
@@ -0,0 +1,195 @@
+package chat
+
+import (
+ "encoding/json"
+ "github.com/golang-jwt/jwt/v5"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "git.simponic.xyz/simponic/phoneof/adapters/messaging"
+ "git.simponic.xyz/simponic/phoneof/api/types"
+ "git.simponic.xyz/simponic/phoneof/database"
+)
+
+func ValidateFren(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ fren_id := req.FormValue("fren_id")
+ fren, err := database.FindFren(context.DBConn, fren_id)
+ if err != nil || fren == nil {
+ log.Printf("err fetching friend %s %s", fren, err)
+ resp.WriteHeader(http.StatusUnauthorized)
+ return failure(context, req, resp)
+ }
+ context.User = fren
+ (*context.TemplateData)["User"] = fren
+ return success(context, req, resp)
+ }
+}
+
+func FetchMessagesContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ before := time.Now().UTC()
+ var err error
+ if req.FormValue("before") != "" {
+ before, err = time.Parse(time.RFC3339, req.FormValue("before"))
+ if err != nil {
+ log.Printf("bad time: %s", err)
+ resp.WriteHeader(http.StatusBadRequest)
+ return failure(context, req, resp)
+ }
+ }
+
+ query := database.ListMessageQuery{
+ FrenId: context.User.Id,
+ Before: before,
+ Limit: 25,
+ }
+ messages, err := database.ListMessages(context.DBConn, query)
+ if err != nil {
+ log.Printf("err listing messages %v %s", query, err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+
+ (*context.TemplateData)["Messages"] = messages
+ return success(context, req, resp)
+ }
+}
+
+func SendMessageContinuation(messagingAdapter messaging.MessagingAdapter) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ rawMessage := req.FormValue("message")
+ now := time.Now().UTC()
+ messageRequestId, err := messagingAdapter.SendMessage(context.User.Name + " " + rawMessage)
+ if err != nil {
+ log.Printf("err sending message %s %s %s", context.User, rawMessage, err)
+ // yeah this might be a 400 or whatever, i’ll fix it later
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+
+ message, err := database.SaveMessage(context.DBConn, &database.Message{
+ Id: messageRequestId,
+ FrenId: context.User.Id,
+ Message: rawMessage,
+ Time: now,
+ FrenSent: true,
+ })
+
+ log.Printf("Saved message %v", message)
+ return success(context, req, resp)
+ }
+ }
+}
+
+type Timestamp struct {
+ time.Time
+}
+
+func (p *Timestamp) UnmarshalJSON(bytes []byte) error {
+ var raw string
+ err := json.Unmarshal(bytes, &raw)
+
+ if err != nil {
+ log.Printf("error decoding timestamp: %s\n", err)
+ return err
+ }
+
+ p.Time, err = time.Parse(time.RFC3339, raw)
+ if err != nil {
+ log.Printf("error decoding timestamp: %s\n", err)
+ return err
+ }
+
+ return nil
+}
+
+type HttpSmsEventData struct {
+ Contact string `json:"contact"`
+ Content string `json:"content"`
+ Owner string `json:"owner"`
+ Timestamp time.Time `json:"timestamp"`
+}
+type HttpSmsEvent struct {
+ Data HttpSmsEventData `json:"data"`
+ Type string `json:"type"`
+ Id string `json:"id"`
+}
+
+func ChatEventProcessorContinuation(signingKey string) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ // check signing
+ joken := strings.Split(req.Header.Get("Authorization"), "Bearer ")
+ _, err := jwt.Parse(joken[1], func(token *jwt.Token) (interface{}, error) {
+ return []byte(signingKey), nil
+ })
+ if err != nil {
+ log.Printf("invalid jwt %s", err)
+ resp.WriteHeader(http.StatusBadRequest)
+ return failure(context, req, resp)
+ }
+
+ // decode the event
+ defer req.Body.Close()
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ log.Printf("err reading body")
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+
+ var event HttpSmsEvent
+ err = json.Unmarshal(body, &event)
+ if err != nil {
+ log.Printf("err unmarshaling body")
+ resp.WriteHeader(http.StatusBadRequest)
+ return failure(context, req, resp)
+ }
+
+ // we only care about received messages
+ if event.Type != "message.phone.received" {
+ log.Printf("got non-receive event %s", event.Type)
+ return success(context, req, resp)
+ }
+
+ // respond to texts with "<fren name> <content>"
+ content := strings.SplitN(event.Data.Content, " ", 2)
+ if len(content) < 2 {
+ log.Printf("no space delimiter")
+ resp.WriteHeader(http.StatusBadRequest)
+ return failure(context, req, resp)
+ }
+ name := content[0]
+ rawMessage := content[1]
+
+ fren, err := database.FindFrenByName(context.DBConn, name)
+ if err != nil {
+ log.Printf("err when getting fren %s %s", name, err)
+ resp.WriteHeader(http.StatusBadRequest)
+ return failure(context, req, resp)
+ }
+
+ // save the message!
+ _, err = database.SaveMessage(context.DBConn, &database.Message{
+ Id: event.Id,
+ FrenId: fren.Id,
+ Message: rawMessage,
+ Time: event.Data.Timestamp,
+ FrenSent: false,
+ })
+ if err != nil {
+ log.Printf("err when saving message %s %s", name, err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return failure(context, req, resp)
+ }
+
+ return success(context, req, resp)
+ }
+ }
+
+}
diff --git a/api/template/template.go b/api/template/template.go
new file mode 100644
index 0000000..266293f
--- /dev/null
+++ b/api/template/template.go
@@ -0,0 +1,73 @@
+package template
+
+import (
+ "bytes"
+ "errors"
+ "html/template"
+ "log"
+ "net/http"
+ "os"
+
+ "git.simponic.xyz/simponic/phoneof/api/types"
+)
+
+func renderTemplate(context *types.RequestContext, templateName string, showBaseHtml bool) (bytes.Buffer, error) {
+ templatePath := context.Args.TemplatePath
+ basePath := templatePath + "/base_empty.html"
+ if showBaseHtml {
+ basePath = templatePath + "/base.html"
+ }
+
+ templateLocation := templatePath + "/" + templateName
+ tmpl, err := template.New("").ParseFiles(templateLocation, basePath)
+ if err != nil {
+ return bytes.Buffer{}, err
+ }
+
+ dataPtr := context.TemplateData
+ if dataPtr == nil {
+ dataPtr = &map[string]interface{}{}
+ }
+
+ data := *dataPtr
+
+ var buffer bytes.Buffer
+ err = tmpl.ExecuteTemplate(&buffer, "base", data)
+
+ if err != nil {
+ return bytes.Buffer{}, err
+ }
+ return buffer, nil
+}
+
+func TemplateContinuation(path string, showBase bool) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ html, err := renderTemplate(context, path, showBase)
+ if errors.Is(err, os.ErrNotExist) {
+ resp.WriteHeader(404)
+ html, err = renderTemplate(context, "404.html", true)
+ if err != nil {
+ log.Println("error rendering 404 template", err)
+ resp.WriteHeader(500)
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return failure(context, req, resp)
+ }
+
+ if err != nil {
+ log.Println("error rendering template", err)
+ resp.WriteHeader(500)
+ resp.Write([]byte("error rendering template"))
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return success(context, req, resp)
+ }
+ }
+}
diff --git a/api/types/types.go b/api/types/types.go
new file mode 100644
index 0000000..2698bbc
--- /dev/null
+++ b/api/types/types.go
@@ -0,0 +1,28 @@
+package types
+
+import (
+ "database/sql"
+ "net/http"
+ "time"
+
+ "git.simponic.xyz/simponic/phoneof/args"
+ "git.simponic.xyz/simponic/phoneof/database"
+)
+
+type RequestContext struct {
+ DBConn *sql.DB
+ Args *args.Arguments
+
+ Id string
+ Start time.Time
+ User *database.Fren
+
+ TemplateData *map[string]interface{}
+}
+
+type BannerMessages struct {
+ Messages []string
+}
+
+type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain
+type ContinuationChain func(Continuation, Continuation) ContinuationChain