commit f163a242792cd325c9414587d52f3d8584f28df1 Author: Elizabeth Hunt Date: Fri Jan 3 01:47:07 2025 -0800 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..844070b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.env +phoneof +Dockerfile +*.db +.drone.yml diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..61a0ae4 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,49 @@ +--- +kind: pipeline +type: docker +name: build + +steps: + - name: run tests + image: golang + commands: + - go get + - go test -p 1 -v ./... + +trigger: + event: + - pull_request + + +--- +kind: pipeline +type: docker +name: cicd + +steps: + - name: ci + image: plugins/docker + settings: + username: + from_secret: gitea_packpub_username + password: + from_secret: gitea_packpub_password + registry: git.simponic.xyz + repo: git.simponic.xyz/simponic/phoneof + - name: ssh + image: appleboy/drone-ssh + settings: + host: ryo.simponic.xyz + username: root + key: + from_secret: cd_ssh_key + port: 22 + command_timeout: 2m + script: + - systemctl restart docker-compose@phoneof + +trigger: + branch: + - main + event: + - push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5223249 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.env +phoneof +*.db +.DS_Store diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..db5d8ee --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.23.4 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3e2d0fb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.23 +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o /app/phoneof + +EXPOSE 8080 + +CMD ["/app/phoneof", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/phoneof.db", "--static-path", "/app/static", "--scheduler"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8802af4 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +## phoneof simponic. + +this is a simponic service for phoneof + +TODO: +- [ ] pagination for messages +- [ ] full text search? +- [ ] better auth lol diff --git a/adapters/messaging/http_sms.go b/adapters/messaging/http_sms.go new file mode 100644 index 0000000..c722f21 --- /dev/null +++ b/adapters/messaging/http_sms.go @@ -0,0 +1,62 @@ +package messaging + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + + "git.simponic.xyz/simponic/phoneof/utils" +) + +type HttpSmsMessagingAdapter struct { + ApiToken string + FromPhoneNumber string + ToPhoneNumber string + Endpoint string +} + +type HttpSmsMessageData struct { + RequestId string `json:"request_id"` +} + +type HttpSmsMessageSendResponse struct { + Data HttpSmsMessageData `json:"data"` +} + +func (adapter *HttpSmsMessagingAdapter) encodeMessage(message string) string { + requestId := utils.RandomId() + return fmt.Sprintf(`{"from":"%s","to":"%s","content":"%s","request_id":"%s"}`, adapter.FromPhoneNumber, adapter.ToPhoneNumber, message, requestId) +} + +func (adapter *HttpSmsMessagingAdapter) SendMessage(message string) (string, error) { + url := fmt.Sprintf("%s/v1/messages/send", adapter.Endpoint) + payload := strings.NewReader(adapter.encodeMessage(message)) + + req, _ := http.NewRequest("POST", url, payload) + req.Header.Add("x-api-key", adapter.ApiToken) + req.Header.Add("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("got err sending message send req %s", err) + return "", err + } + + if res.StatusCode/100 != 2 { + return "", fmt.Errorf("error sending message: %s %d", message, res.StatusCode) + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + var response HttpSmsMessageSendResponse + err = json.Unmarshal(body, &response) + if err != nil { + log.Printf("got error unmarshaling response: %s %s", body, err) + return "", err + } + + return response.Data.RequestId, nil +} diff --git a/adapters/messaging/messaging.go b/adapters/messaging/messaging.go new file mode 100644 index 0000000..607258a --- /dev/null +++ b/adapters/messaging/messaging.go @@ -0,0 +1,5 @@ +package messaging + +type MessagingAdapter interface { + SendMessage(message string) (string, error) +} 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 " " + 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 diff --git a/args/args.go b/args/args.go new file mode 100644 index 0000000..458d36e --- /dev/null +++ b/args/args.go @@ -0,0 +1,87 @@ +package args + +import ( + "flag" + "fmt" + "os" + "sync" +) + +type Arguments struct { + DatabasePath string + TemplatePath string + StaticPath string + + Migrate bool + Scheduler bool + + HttpSmsEndpoint string + + Port int + Server bool +} + +func isDirectory(path string) (bool, error) { + fileInfo, err := os.Stat(path) + if err != nil { + return false, err + } + + return fileInfo.IsDir(), err +} + +func validateArgs(args *Arguments) error { + templateIsDir, err := isDirectory(args.TemplatePath) + if err != nil || !templateIsDir { + return fmt.Errorf("template path is not an accessible directory %s", err) + } + staticPathIsDir, err := isDirectory(args.StaticPath) + if err != nil || !staticPathIsDir { + return fmt.Errorf("static path is not an accessible directory %s", err) + } + return nil +} + +var lock = &sync.Mutex{} +var args *Arguments + +func GetArgs() (*Arguments, error) { + lock.Lock() + defer lock.Unlock() + + if args != nil { + return args, nil + } + + databasePath := flag.String("database-path", "./phoneof.db", "Path to the SQLite database") + + templatePath := flag.String("template-path", "./templates", "Path to the template directory") + staticPath := flag.String("static-path", "./static", "Path to the static directory") + + httpSmsEndpoint := flag.String("httpsms-endpoint", "https://httpsms.com", "HTTPSMS endpoint") + + scheduler := flag.Bool("scheduler", false, "Run scheduled jobs via cron") + migrate := flag.Bool("migrate", false, "Run the migrations") + + port := flag.Int("port", 8080, "Port to listen on") + server := flag.Bool("server", false, "Run the server") + + flag.Parse() + + args = &Arguments{ + DatabasePath: *databasePath, + TemplatePath: *templatePath, + StaticPath: *staticPath, + Port: *port, + Server: *server, + Migrate: *migrate, + Scheduler: *scheduler, + HttpSmsEndpoint: *httpSmsEndpoint, + } + err := validateArgs(args) + if err != nil { + return nil, err + } + + return args, nil +} diff --git a/database/conn.go b/database/conn.go new file mode 100644 index 0000000..be27586 --- /dev/null +++ b/database/conn.go @@ -0,0 +1,17 @@ +package database + +import ( + "database/sql" + _ "github.com/mattn/go-sqlite3" + "log" +) + +func MakeConn(databasePath *string) *sql.DB { + log.Println("opening database at", *databasePath, "with foreign keys enabled") + dbConn, err := sql.Open("sqlite3", *databasePath+"?_foreign_keys=on") + if err != nil { + panic(err) + } + + return dbConn +} diff --git a/database/frens.go b/database/frens.go new file mode 100644 index 0000000..b8b72ad --- /dev/null +++ b/database/frens.go @@ -0,0 +1,33 @@ +package database + +import ( + "database/sql" + "log" +) + +type Fren struct { + Id string + Name string +} + +func FindFren(dbConn *sql.DB, id string) (*Fren, error) { + row := dbConn.QueryRow(`SELECT id, name FROM frens WHERE id = ?;`, id) + var fren Fren + err := row.Scan(&fren.Id, &fren.Name) + if err != nil { + log.Println(err) + return nil, err + } + return &fren, nil +} + +func FindFrenByName(dbConn *sql.DB, name string) (*Fren, error) { + row := dbConn.QueryRow(`SELECT id, name FROM frens WHERE name = ?;`, name) + var fren Fren + err := row.Scan(&fren.Id, &fren.Name) + if err != nil { + log.Println(err) + return nil, err + } + return &fren, nil +} diff --git a/database/messages.go b/database/messages.go new file mode 100644 index 0000000..7646b50 --- /dev/null +++ b/database/messages.go @@ -0,0 +1,49 @@ +package database + +import ( + "database/sql" + "time" +) + +type Message struct { + Id string + FrenId string + Message string + Time time.Time + FrenSent bool +} + +type ListMessageQuery struct { + FrenId string + Before time.Time + Limit int +} + +func ListMessages(dbConn *sql.DB, query ListMessageQuery) ([]Message, error) { + rows, err := dbConn.Query(`SELECT id, fren_id, message, time, fren_sent FROM messages WHERE fren_id = ? AND time < ? ORDER BY time ASC LIMIT ?;`, query.FrenId, query.Before, query.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + + messages := []Message{} + for rows.Next() { + var message Message + err := rows.Scan(&message.Id, &message.FrenId, &message.Message, &message.Time, &message.FrenSent) + if err != nil { + return nil, err + } + messages = append(messages, message) + } + + return messages, nil +} + +func SaveMessage(db *sql.DB, message *Message) (*Message, error) { + _, err := db.Exec("INSERT OR REPLACE INTO messages (id, fren_id, message, time, fren_sent) VALUES (?, ?, ?, ?, ?)", message.Id, message.FrenId, message.Message, message.Time, message.FrenSent) + + if err != nil { + return nil, err + } + return message, nil +} diff --git a/database/migrate.go b/database/migrate.go new file mode 100644 index 0000000..a6b63f8 --- /dev/null +++ b/database/migrate.go @@ -0,0 +1,70 @@ +package database + +import ( + "log" + + "database/sql" + + _ "github.com/mattn/go-sqlite3" +) + +type Migrator func(*sql.DB) (*sql.DB, error) + +func AddFriends(dbConn *sql.DB) (*sql.DB, error) { + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS frens ( + id TEXT PRIMARY KEY, + name TEXT + );`) + if err != nil { + return dbConn, err + } + + log.Println("creating unique index on frens table") + _, err = dbConn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_frens_name ON frens (name);`) + if err != nil { + return dbConn, err + } + + return dbConn, nil +} + +func AddMessages(dbConn *sql.DB) (*sql.DB, error) { + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + fren_id TEXT, + message TEXT, + time TIMESTAMP, + fren_sent BOOLEAN NOT NULL CHECK (fren_sent IN (0, 1)), + FOREIGN KEY (fren_id) REFERENCES frens (id) ON DELETE CASCADE + );`) + if err != nil { + return dbConn, err + } + + log.Println("creating time and fren_id index on message table") + _, err = dbConn.Exec(`CREATE INDEX IF NOT EXISTS idx_message_timestamp ON messages (time);`) + _, err = dbConn.Exec(`CREATE INDEX IF NOT EXISTS idx_message_fren ON messages (fren_id);`) + if err != nil { + return dbConn, err + } + + return dbConn, nil +} + +func Migrate(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating database") + + migrations := []Migrator{ + AddFriends, + AddMessages, + } + + for _, migration := range migrations { + dbConn, err := migration(dbConn) + if err != nil { + return dbConn, err + } + } + + return dbConn, nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd3fcac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3" + +services: + api: + restart: always + image: git.simponic.xyz/simponic/phoneof + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:8080/api/health"] + interval: 5s + timeout: 10s + retries: 5 + env_file: .env + volumes: + - ./db:/app/db + ports: + - "127.0.0.1:6363:8080" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b3ba518 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.simponic.xyz/simponic/phoneof + +go 1.23.4 + +require ( + github.com/go-co-op/gocron/v2 v2.14.0 + github.com/joho/godotenv v1.5.1 + github.com/mattn/go-sqlite3 v1.14.24 +) + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jonboulle/clockwork v0.4.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b3b2f7d --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-co-op/gocron/v2 v2.14.0 h1:bWPJeIdd4ioqiEpLLD1BVSTrtae7WABhX/WaVJbKVqg= +github.com/go-co-op/gocron/v2 v2.14.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= +github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b825015 --- /dev/null +++ b/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "git.simponic.xyz/simponic/phoneof/api" + "git.simponic.xyz/simponic/phoneof/args" + "git.simponic.xyz/simponic/phoneof/database" + "git.simponic.xyz/simponic/phoneof/scheduler" + "github.com/joho/godotenv" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + err := godotenv.Load() + if err != nil { + log.Println("could not load .env file:", err) + } + + argv, err := args.GetArgs() + if err != nil { + log.Fatal(err) + } + + dbConn := database.MakeConn(&argv.DatabasePath) + defer dbConn.Close() + + if argv.Migrate { + _, err = database.Migrate(dbConn) + if err != nil { + log.Fatal(err) + } + log.Println("database migrated successfully") + } + + if argv.Scheduler { + go func() { + scheduler.StartScheduler(dbConn, argv) + }() + } + + if argv.Server { + mux := api.MakeMux(argv, dbConn) + log.Println("🚀🚀 phoneof API listening on port", argv.Port) + go func() { + server := &http.Server{ + Addr: ":" + fmt.Sprint(argv.Port), + Handler: mux, + } + err = server.ListenAndServe() + if err != nil { + log.Fatal(err) + } + }() + } + + if argv.Server || argv.Scheduler { + select {} // block forever + } +} diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go new file mode 100644 index 0000000..7e521e1 --- /dev/null +++ b/scheduler/scheduler.go @@ -0,0 +1,34 @@ +package scheduler + +import ( + "database/sql" + "log" + "time" + + "git.simponic.xyz/simponic/phoneof/args" + "github.com/go-co-op/gocron/v2" +) + +func StartScheduler(_dbConn *sql.DB, argv *args.Arguments) { + scheduler, err := gocron.NewScheduler() + if err != nil { + panic("could not create scheduler") + } + + _, err = scheduler.NewJob( + gocron.DurationJob( + 24*time.Hour, + ), + gocron.NewTask( + func(msg string) { + log.Println(msg) + }, + "it's a beautiful new day!", + ), + ) + if err != nil { + panic("could not create job") + } + + scheduler.Start() +} diff --git a/static/css/chat.css b/static/css/chat.css new file mode 100644 index 0000000..261fffa --- /dev/null +++ b/static/css/chat.css @@ -0,0 +1,46 @@ +.chat-container { + background-color: var(--container-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + width: 90%; + max-height: 70vh; + margin: 2rem auto; + padding: 1rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + overflow-y: auto; +} + +.message { + display: flex; + margin-bottom: 1rem; +} + +.message:last-child { + margin-bottom: 0; +} + +.message.fren { + justify-content: flex-end; +} + +.message-text { + background-color: var(--background-color-2); + color: var(--text-color); + padding: 0.5rem 1rem; + border-radius: 4px; + max-width: 70%; + word-wrap: break-word; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.message-text.fren { + background-color: var(--confirm-color); + color: white; +} + +.timestamp { + font-size: 0.75rem; + color: var(--text-color); + margin-top: 0.5rem; + text-align: right; +} diff --git a/static/css/colors.css b/static/css/colors.css new file mode 100644 index 0000000..e40f80c --- /dev/null +++ b/static/css/colors.css @@ -0,0 +1,55 @@ +/* Colors inspired by "day/night-fox" schemes */ +:root { + /* Light mode colors */ + --background-color-light: #f6f2ee; /* base00 */ + --background-color-light-2: #dbd1dd; /* base01 */ + --text-color-light: #3d2b5a; /* base05 */ + --confirm-color-light: #396847; /* base0B */ + --link-color-light: #6e33ce; /* base0E */ + --container-bg-light: #f4ece6; /* base07 */ + --border-color-light: #2848a9; /* base0D */ + --error-color-light: #a5222f; /* base08 */ + + /* Dark mode colors */ + --background-color-dark: #192330; /* base00 */ + --background-color-dark-2: #212e3f; /* base01 */ + --text-color-dark: #cdcecf; /* base05 */ + --confirm-color-dark: #81b29a; /* base0B */ + --link-color-dark: #9d79d6; /* base0E */ + --container-bg-dark: #29394f; /* base02 */ + --border-color-dark: #719cd6; /* base0D */ + --error-color-dark: #c94f6d; /* base08 */ +} + + +[data-theme="DARK"] { + --background-color: var(--background-color-dark); + --background-color-2: var(--background-color-dark-2); + --text-color: var(--text-color-dark); + --link-color: var(--link-color-dark); + --container-bg: var(--container-bg-dark); + --border-color: var(--border-color-dark); + --error-color: var(--error-color-dark); + --confirm-color: var(--confirm-color-dark); +} + +[data-theme="LIGHT"] { + --background-color: var(--background-color-light); + --background-color-2: var(--background-color-light-2); + --text-color: var(--text-color-light); + --link-color: var(--link-color-light); + --container-bg: var(--container-bg-light); + --border-color: var(--border-color-light); + --error-color: var(--error-color-light); + --confirm-color: var(--confirm-color-light); +} + +.error { + background-color: var(--error-color); + padding: 1rem; +} + +.success { + background-color: var(--confirm-color); + padding: 1rem; +} diff --git a/static/css/form.css b/static/css/form.css new file mode 100644 index 0000000..7ccd8db --- /dev/null +++ b/static/css/form.css @@ -0,0 +1,42 @@ +.form { + max-width: 600px; + padding: 1em; + background: var(--background-color-2); + border: 1px solid #ccc; +} + +label { + display: block; + margin: 0 0 1em; + font-weight: bold; +} + +input { + display: block; + width: 100%; + padding: 0.5em; + margin: 0 0 1em; + border: 1px solid var(--border-color); + background: var(--container-bg); +} + +button, +input[type="submit"] { + padding: 0.5em 1em; + background: var(--link-color); + color: var(--text-color); + border: 0; + cursor: pointer; +} + +textarea { + display: block; + width: 100%; + padding: 0.5em; + margin: 0 0 1em; + border: 1px solid var(--border-color); + background: var(--container-bg); + + resize: vertical; + min-height: 100px; +} diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..2ec823a --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,60 @@ +@import "/static/css/colors.css"; +@import "/static/css/form.css"; +@import "/static/css/table.css"; +@import "/static/css/chat.css"; + +@font-face { + font-family: 'GeistMono'; + src: url('/static/fonts/GeistMono-Medium.ttf') format('truetype'); +} + +* { + box-sizing: border-box; + font-family: GeistMono; +} + +html { + margin: 0; + padding: 0; + color: var(--text-color); +} + +body { + background-color: var(--background-color); + min-height: 100vh; +} + +hr { + border: 0; + border-top: 1px solid var(--text-color); + + margin: 20px 0; +} + +.container { + max-width: 1600px; + margin: auto; + background-color: var(--container-bg); + padding: 1rem; +} + + +a { + color: var(--link-color); + text-decoration: none; + font-weight: bold; +} +a:hover { + text-decoration: underline; +} + +.info { + margin-bottom: 1rem; + max-width: 600px; + + transition: opacity 0.3s; +} + +.info:hover { + opacity: 0.8; +} diff --git a/static/css/table.css b/static/css/table.css new file mode 100644 index 0000000..16da86d --- /dev/null +++ b/static/css/table.css @@ -0,0 +1,28 @@ +@import "/static/css/colors.css"; + +table { + width: auto; + border-collapse: collapse; + border: 1px solid var(--border-color); +} + +th, +td { + padding: 12px 20px; + text-align: center; + border-bottom: 1px solid var(--border-color); +} + +th, +thead { + background-color: var(--background-color-2); +} + +tbody tr:nth-child(odd) { + background-color: var(--background-color); + color: var(--text-color); +} + +tbody tr { + transition: background-color 0.3s ease; +} diff --git a/static/fonts/GeistMono-Medium.ttf b/static/fonts/GeistMono-Medium.ttf new file mode 100644 index 0000000..4284eb4 Binary files /dev/null and b/static/fonts/GeistMono-Medium.ttf differ diff --git a/static/img/favicon.ico b/static/img/favicon.ico new file mode 100644 index 0000000..58c53c3 Binary files /dev/null and b/static/img/favicon.ico differ diff --git a/static/js/components/chat.js b/static/js/components/chat.js new file mode 100644 index 0000000..8286a37 --- /dev/null +++ b/static/js/components/chat.js @@ -0,0 +1,27 @@ +const runChat = async () => { + const frenId = + new URLSearchParams(document.location.search).get("fren_id") ?? + document.getElementById("fren_id").value; + const html = await fetch(`/chat/messages?fren_id=${frenId}`).then((r) => + r.text(), + ); + + const { scrollTop, scrollHeight } = document.getElementById( + "chat-container", + ) ?? { scrollTop: 0 }; + const isAtEdge = scrollTop === scrollHeight || scrollTop === 0; + document.getElementById("messages").innerHTML = html; + if (isAtEdge) { + document.getElementById("chat-container").scrollTop = + document.getElementById("chat-container").scrollHeight; + } else { + // save the position. + document.getElementById("chat-container").scrollTop = scrollTop; + } +}; + +setTimeout(() => { + if (document.location.pathname === "/chat") { + runChat().then(() => setInterval(runChat, 5_000)); + } +}, 200); diff --git a/static/js/components/infoBanners.js b/static/js/components/infoBanners.js new file mode 100644 index 0000000..6a19864 --- /dev/null +++ b/static/js/components/infoBanners.js @@ -0,0 +1,6 @@ +const infoBanners = document.querySelectorAll(".info"); +Array.from(infoBanners).forEach((infoBanner) => { + infoBanner.addEventListener("click", () => { + infoBanner.remove(); + }); +}); diff --git a/static/js/components/themeSwitcher.js b/static/js/components/themeSwitcher.js new file mode 100644 index 0000000..e5497f0 --- /dev/null +++ b/static/js/components/themeSwitcher.js @@ -0,0 +1,27 @@ +const THEMES = { + DARK: "DARK", + LIGHT: "LIGHT", +}; + +const flipFlopTheme = (theme) => + THEMES[theme] === THEMES.DARK ? THEMES.LIGHT : THEMES.DARK; + +const themePickerText = { + DARK: "light mode.", + LIGHT: "dark mode.", +}; + +const themeSwitcher = document.getElementById("theme-switcher"); + +const setTheme = (theme) => { + themeSwitcher.textContent = `${themePickerText[theme]}`; + + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("theme", theme); +}; + +themeSwitcher.addEventListener("click", () => + setTheme(flipFlopTheme(document.documentElement.getAttribute("data-theme"))), +); + +setTheme(localStorage.getItem("theme") || THEMES.LIGHT); diff --git a/static/js/require.js b/static/js/require.js new file mode 100644 index 0000000..a4203f0 --- /dev/null +++ b/static/js/require.js @@ -0,0 +1,5 @@ +/** vim: et:ts=4:sw=4:sts=4 + * @license RequireJS 2.3.6 Copyright jQuery Foundation and other contributors. + * Released under MIT license, https://github.com/requirejs/requirejs/blob/master/LICENSE + */ +var requirejs,require,define;!function(global,setTimeout){var req,s,head,baseElement,dataMain,src,interactiveScript,currentlyAddingScript,mainScript,subPath,version="2.3.6",commentRegExp=/\/\*[\s\S]*?\*\/|([^:"'=]|^)\/\/.*$/gm,cjsRequireRegExp=/[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g,jsSuffixRegExp=/\.js$/,currDirRegExp=/^\.\//,op=Object.prototype,ostring=op.toString,hasOwn=op.hasOwnProperty,isBrowser=!("undefined"==typeof window||"undefined"==typeof navigator||!window.document),isWebWorker=!isBrowser&&"undefined"!=typeof importScripts,readyRegExp=isBrowser&&"PLAYSTATION 3"===navigator.platform?/^complete$/:/^(complete|loaded)$/,defContextName="_",isOpera="undefined"!=typeof opera&&"[object Opera]"===opera.toString(),contexts={},cfg={},globalDefQueue=[],useInteractive=!1;function commentReplace(e,t){return t||""}function isFunction(e){return"[object Function]"===ostring.call(e)}function isArray(e){return"[object Array]"===ostring.call(e)}function each(e,t){var i;if(e)for(i=0;ipage not found +

but hey, at least you found our witty 404 page. that's something, right?

+ +

go back home

+ +{{ end }} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..e97e8d2 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,34 @@ +{{ define "base" }} + + + + phoneof simponic. + + + + + + + + + + + + + + + +
+
+

phoneof

+ home. + | + light mode. +
+
+ {{ template "content" . }} +
+ + + +{{ end }} diff --git a/templates/base_empty.html b/templates/base_empty.html new file mode 100644 index 0000000..6191ab9 --- /dev/null +++ b/templates/base_empty.html @@ -0,0 +1,3 @@ +{{ define "base" }} + {{ template "content" . }} +{{ end }} \ No newline at end of file diff --git a/templates/chat.html b/templates/chat.html new file mode 100644 index 0000000..05957bf --- /dev/null +++ b/templates/chat.html @@ -0,0 +1,11 @@ +{{ define "content" }} +
+
+
+
+ + + +
+
+{{ end }} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..7e8ecb7 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,8 @@ +{{ define "content" }} +

please use this if you know who i am and know how to use this but need to get ahold of me while im away from computers.

+
+ + + +
+{{ end }} diff --git a/templates/messages.html b/templates/messages.html new file mode 100644 index 0000000..e09ac46 --- /dev/null +++ b/templates/messages.html @@ -0,0 +1,12 @@ +{{ define "content" }} +
+ {{ range $message := .Messages }} +
+
+

{{$message.Message}}

+
{{$message.Time.Format "2006-01-02T15:04:05Z07:00"}}
+
+
+ {{ end }} +
+{{ end }} diff --git a/utils/random_id.go b/utils/random_id.go new file mode 100644 index 0000000..1b03ec8 --- /dev/null +++ b/utils/random_id.go @@ -0,0 +1,16 @@ +package utils + +import ( + "crypto/rand" + "fmt" +) + +func RandomId() string { + id := make([]byte, 16) + _, err := rand.Read(id) + if err != nil { + panic(err) + } + + return fmt.Sprintf("%x", id) +}