initial commit
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Elizabeth Hunt 2025-01-03 01:47:07 -08:00
commit f163a24279
Signed by: simponic
GPG Key ID: 2909B9A7FF6213EE
43 changed files with 1469 additions and 0 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.env
phoneof
Dockerfile
*.db
.drone.yml

49
.drone.yml Normal file
View File

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

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.env
phoneof
*.db
.DS_Store

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
golang 1.23.4

13
Dockerfile Normal file
View File

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

8
README.md Normal file
View File

@ -0,0 +1,8 @@
## phoneof simponic.
this is a simponic service for phoneof
TODO:
- [ ] pagination for messages
- [ ] full text search?
- [ ] better auth lol

View File

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

View File

@ -0,0 +1,5 @@
package messaging
type MessagingAdapter interface {
SendMessage(message string) (string, error)
}

121
api/api.go Normal file
View File

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

89
api/api_test.go Normal file
View File

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

195
api/chat/chat.go Normal file
View File

@ -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, ill 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)
}
}
}

73
api/template/template.go Normal file
View File

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

28
api/types/types.go Normal file
View File

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

87
args/args.go Normal file
View File

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

17
database/conn.go Normal file
View File

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

33
database/frens.go Normal file
View File

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

49
database/messages.go Normal file
View File

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

70
database/migrate.go Normal file
View File

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

16
docker-compose.yml Normal file
View File

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

17
go.mod Normal file
View File

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

26
go.sum Normal file
View File

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

63
main.go Normal file
View File

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

34
scheduler/scheduler.go Normal file
View File

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

46
static/css/chat.css Normal file
View File

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

55
static/css/colors.css Normal file
View File

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

42
static/css/form.css Normal file
View File

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

60
static/css/styles.css Normal file
View File

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

28
static/css/table.css Normal file
View File

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

Binary file not shown.

BIN
static/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

View File

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

View File

@ -0,0 +1,6 @@
const infoBanners = document.querySelectorAll(".info");
Array.from(infoBanners).forEach((infoBanner) => {
infoBanner.addEventListener("click", () => {
infoBanner.remove();
});
});

View File

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

5
static/js/require.js Normal file

File diff suppressed because one or more lines are too long

6
static/js/script.js Normal file
View File

@ -0,0 +1,6 @@
const scripts = [
"/static/js/components/themeSwitcher.js",
"/static/js/components/infoBanners.js",
"/static/js/components/chat.js",
];
requirejs(scripts);

View File

@ -0,0 +1,11 @@
const preferredMode = window.matchMedia("(prefers-color-scheme: dark)").matches
? "DARK"
: "LIGHT";
// sets theme before rendering & jquery loaded to prevent flashing of uninitialized theme
// (ugly white background)
localStorage.setItem("theme", localStorage.getItem("theme") || preferredMode);
document.documentElement.setAttribute(
"data-theme",
localStorage.getItem("theme"),
);

7
templates/404.html Normal file
View File

@ -0,0 +1,7 @@
{{ define "content" }}
<h1>page not found</h1>
<p><em>but hey, at least you found our witty 404 page. that's something, right?</em></p>
<p><a href="/">go back home</a></p>
{{ end }}

34
templates/base.html Normal file
View File

@ -0,0 +1,34 @@
{{ define "base" }}
<!DOCTYPE html>
<html>
<head>
<title>phoneof simponic.</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link href="/static/img/favicon.ico" rel="icon" type="image/x-icon">
<link rel="stylesheet" type="text/css" href="/static/css/styles.css">
<meta property="og:type" content="website">
<meta property="og:url" content="https://phoneof.simponic.xyz">
<meta property="og:title" content="phoneof simponic.">
<meta property="og:description" content="phoneof simponic.">
<meta property="og:image:secure_url" content="https://phoneof.simponic.xyz/static/img/favicon.ico">
<meta property="og:image:secure" content="https://phoneof.simponic.xyz/static/img/favicon.ico">
<script src="/static/js/util/setThemeBeforeRender.js"></script>
</head>
<body>
<div id="content" class="container">
<div>
<h1>phoneof</h1>
<a href="/">home.</a>
<span> | </span>
<a href="javascript:void(0);" id="theme-switcher">light mode.</a>
</div>
<hr>
{{ template "content" . }}
</div>
<script data-main="/static/js/script.js" src="/static/js/require.js"></script>
</body>
</html>
{{ end }}

View File

@ -0,0 +1,3 @@
{{ define "base" }}
{{ template "content" . }}
{{ end }}

11
templates/chat.html Normal file
View File

@ -0,0 +1,11 @@
{{ define "content" }}
<div id="messages">
</div>
<div>
<form action="/chat" method="POST" autocomplete="off">
<input id="fren_id" name="fren_id" value="{{ .User.Id }}" type="hidden">
<input name="message" value="" type="text">
<input type="submit" value="send.">
</form>
</div>
{{ end }}

8
templates/home.html Normal file
View File

@ -0,0 +1,8 @@
{{ define "content" }}
<p>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.</p>
<form action="/chat" method="GET" autocomplete="off">
<label for="fren_id">fren id.</label>
<input type="text" id="fren_id" name="fren_id">
<input type="submit" value="login.">
</form>
{{ end }}

12
templates/messages.html Normal file
View File

@ -0,0 +1,12 @@
{{ define "content" }}
<div id="chat-container" class="chat-container">
{{ range $message := .Messages }}
<div class="message {{if $message.FrenSent}}fren{{end}}">
<div class="message-text {{if $message.FrenSent}}fren{{end}}">
<p>{{$message.Message}}</p>
<div class="time timestamp">{{$message.Time.Format "2006-01-02T15:04:05Z07:00"}}</div>
</div>
</div>
{{ end }}
</div>
{{ end }}

16
utils/random_id.go Normal file
View File

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