Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
bin/
dist/
.DS_Store
web/node_modules/
web/dist/
web/.wrangler/
web/*.tsbuildinfo
web/vite.config.js
web/vite.config.d.ts
web/tailwind.config.js
web/tailwind.config.d.ts
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ CLI := imsg-bridge-cli
VERSION := $(shell tr -d '\n' < VERSION)
BUILD_LDFLAGS := -X github.com/kacy/imsg-bridge/internal/buildinfo.Version=$(VERSION)

.PHONY: build build-cli test fmt run clean dist release release-patch release-minor version
.PHONY: build build-cli test fmt run clean dist release release-patch release-minor version web-install web-dev web-build web-test

build:
mkdir -p bin
Expand All @@ -23,7 +23,7 @@ run:
go run -ldflags "$(BUILD_LDFLAGS)" ./cmd/imsg-bridge

clean:
rm -rf bin dist
rm -rf bin dist web/dist web/*.tsbuildinfo web/vite.config.js web/vite.config.d.ts web/tailwind.config.js web/tailwind.config.d.ts

dist:
sh ./scripts/build-release.sh $(VERSION)
Expand All @@ -39,3 +39,15 @@ release-patch:

release-minor:
sh ./scripts/release.sh minor

web-install:
cd web && npm install

web-dev:
cd web && npm run dev

web-build:
cd web && npm run build

web-test:
cd web && npm test
127 changes: 127 additions & 0 deletions internal/api/history_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package api

import (
"encoding/json"
"sync"
"time"

"github.com/kacy/imsg-bridge/internal/imsg"
)

type messageHistoryCache struct {
mu sync.RWMutex
ttl time.Duration
entries map[string]cachedMessages
}

type cachedMessages struct {
chatID int64
expiresAt time.Time
messages []imsg.Message
}

func newMessageHistoryCache(ttl time.Duration) *messageHistoryCache {
if ttl <= 0 {
ttl = 30 * time.Minute
}

return &messageHistoryCache{
ttl: ttl,
entries: make(map[string]cachedMessages),
}
}

func (c *messageHistoryCache) Get(chatID int64, opts imsg.ListMessagesOptions) ([]imsg.Message, bool) {
key := messageHistoryKey(chatID, opts)

c.mu.RLock()
entry, ok := c.entries[key]
c.mu.RUnlock()
if !ok {
return nil, false
}

if time.Now().After(entry.expiresAt) {
c.mu.Lock()
delete(c.entries, key)
c.mu.Unlock()
return nil, false
}

return cloneMessages(entry.messages), true
}

func (c *messageHistoryCache) Put(chatID int64, opts imsg.ListMessagesOptions, messages []imsg.Message) {
key := messageHistoryKey(chatID, opts)

c.mu.Lock()
c.entries[key] = cachedMessages{
chatID: chatID,
expiresAt: time.Now().Add(c.ttl),
messages: cloneMessages(messages),
}
c.mu.Unlock()
}

func (c *messageHistoryCache) InvalidateChat(chatID int64) {
c.mu.Lock()
defer c.mu.Unlock()

for key, entry := range c.entries {
if entry.chatID == chatID {
delete(c.entries, key)
}
}
}

func messageHistoryKey(chatID int64, opts imsg.ListMessagesOptions) string {
type key struct {
ChatID int64 `json:"chat_id"`
Limit int `json:"limit"`
Before *string `json:"before,omitempty"`
After *string `json:"after,omitempty"`
Attachments bool `json:"attachments"`
}

var before *string
if opts.Before != nil {
value := opts.Before.UTC().Format(time.RFC3339Nano)
before = &value
}

var after *string
if opts.After != nil {
value := opts.After.UTC().Format(time.RFC3339Nano)
after = &value
}

body, _ := json.Marshal(key{
ChatID: chatID,
Limit: opts.Limit,
Before: before,
After: after,
Attachments: opts.Attachments,
})

return string(body)
}

func cloneMessages(messages []imsg.Message) []imsg.Message {
if len(messages) == 0 {
return nil
}

cloned := make([]imsg.Message, len(messages))
for index, message := range messages {
copyMessage := message
if len(message.Attachments) > 0 {
copyMessage.Attachments = append([]imsg.Attachment(nil), message.Attachments...)
}
if len(message.Reactions) > 0 {
copyMessage.Reactions = append([]imsg.Reaction(nil), message.Reactions...)
}
cloned[index] = copyMessage
}

return cloned
}
53 changes: 52 additions & 1 deletion internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Server struct {
auth Authenticator
pairing *auth.Service
hub *events.Hub
history *messageHistoryCache
limiter *rateLimiter
runner Runner
webhooks WebhookService
Expand All @@ -49,6 +50,7 @@ func NewServer(cfg Config, runner Runner, hub *events.Hub, pairing *auth.Service
cfg: cfg,
pairing: pairing,
hub: hub,
history: newMessageHistoryCache(30 * time.Minute),
limiter: newRateLimiter(),
runner: runner,
webhooks: webhooks,
Expand All @@ -57,6 +59,9 @@ func NewServer(cfg Config, runner Runner, hub *events.Hub, pairing *auth.Service
if pairing != nil {
s.auth = pairing
}
if hub != nil {
s.watchHistoryInvalidation()
}

if s.cfg.TailscaleIP == "" {
s.cfg.TailscaleIP = detectTailscaleIP()
Expand Down Expand Up @@ -144,6 +149,18 @@ func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
Attachments: true,
}

if raw := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("attachments"))); raw != "" {
switch raw {
case "1", "true", "yes":
opts.Attachments = true
case "0", "false", "no":
opts.Attachments = false
default:
writeError(w, http.StatusBadRequest, "bad_request", "attachments must be a boolean")
return
}
}

if before := strings.TrimSpace(r.URL.Query().Get("before")); before != "" {
ts, err := time.Parse(time.RFC3339, before)
if err != nil {
Expand All @@ -167,14 +184,30 @@ func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
return
}

if s.history != nil {
if cached, ok := s.history.Get(chatID, opts); ok {
w.Header().Set("X-Bridge-History-Cache", "hit")
w.Header().Set("X-Bridge-History-Load-Ms", "0")
writeJSON(w, http.StatusOK, map[string][]imsg.Message{"messages": cached})
return
}
}

start := time.Now()
messages, err := s.runner.ListMessages(r.Context(), chatID, opts)
if err != nil {
writeError(w, statusForErr(err), "internal", err.Error())
return
}
if s.attachments != nil {
if s.attachments != nil && opts.Attachments {
s.attachments.DecorateMessages(messages)
}
if s.history != nil {
s.history.Put(chatID, opts, messages)
}

w.Header().Set("X-Bridge-History-Cache", "miss")
w.Header().Set("X-Bridge-History-Load-Ms", strconv.FormatInt(time.Since(start).Milliseconds(), 10))

writeJSON(w, http.StatusOK, map[string][]imsg.Message{"messages": messages})
}
Expand Down Expand Up @@ -212,6 +245,24 @@ func statusForErr(err error) int {
return http.StatusInternalServerError
}

func (s *Server) watchHistoryInvalidation() {
eventsCh, _ := s.hub.Subscribe(32)

go func() {
for event := range eventsCh {
message, ok := event.Data.(imsg.Message)
if !ok {
continue
}
if message.ChatID == 0 || s.history == nil {
continue
}

s.history.InvalidateChat(message.ChatID)
}
}()
}

func detectTailscaleIP() string {
ifaces, err := net.Interfaces()
if err != nil {
Expand Down
Loading
Loading