diff options
author | Elizabeth Hunt <elizabeth.hunt@simponic.xyz> | 2025-01-03 01:47:07 -0800 |
---|---|---|
committer | Elizabeth Hunt <elizabeth.hunt@simponic.xyz> | 2025-01-03 01:47:07 -0800 |
commit | f163a242792cd325c9414587d52f3d8584f28df1 (patch) | |
tree | b57ad121cc3f4ffc2bc55f4d63bfaaf6026dd239 /api | |
download | phoneof-f163a242792cd325c9414587d52f3d8584f28df1.tar.gz phoneof-f163a242792cd325c9414587d52f3d8584f28df1.zip |
initial commit
Diffstat (limited to 'api')
-rw-r--r-- | api/api.go | 121 | ||||
-rw-r--r-- | api/api_test.go | 89 | ||||
-rw-r--r-- | api/chat/chat.go | 195 | ||||
-rw-r--r-- | api/template/template.go | 73 | ||||
-rw-r--r-- | api/types/types.go | 28 |
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 |