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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Per-user topics in the shared project, with resources an admin creates:

There is no setup with a baked-in build. The first run opens a browser for login, and the token is stored in the OS keychain. On Linux, the keychain is the Secret Service over D-Bus, such as GNOME Keyring or KeePassXC. Without one, mutter can't store the token.

To use a different OAuth client, for example during development, write it to `~/Library/Application Support/mutter/config.json` (macOS) or `~/.config/mutter/config.json` (Linux). The file takes precedence over the baked-in client:
To use a different OAuth client, for example during development, write it to `~/Library/Application Support/mutter/config.json` (macOS) or `~/.config/mutter/config.json` (Linux). The file takes precedence over the baked-in client. A file with only `topic` keeps the baked-in client:

```json
{"client_id": "....apps.googleusercontent.com", "client_secret": "...", "topic": "projects/<PROJECT_ID>/topics/mutter-events"}
Expand Down
2 changes: 1 addition & 1 deletion actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,5 +311,5 @@ func senderName(msg *chat.Message) string {
if msg.Sender == nil {
return ""
}
return cmp.Or(msg.Sender.DisplayName, msg.Sender.Name)
return clean(cmp.Or(msg.Sender.DisplayName, msg.Sender.Name))
}
25 changes: 16 additions & 9 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,23 @@ func login(ctx context.Context, cfg *oauth2.Config) (*oauth2.Token, error) {
ch := make(chan result, 1)
srv := &http.Server{ReadHeaderTimeout: 10 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
switch {
case q.Get("state") != state:
http.Error(w, "state mismatch", http.StatusBadRequest)
ch <- result{err: errors.New("oauth state mismatch")}
case q.Get("error") != "":
http.Error(w, q.Get("error"), http.StatusBadRequest)
ch <- result{err: fmt.Errorf("oauth: %s", q.Get("error"))}
default:
// Only the redirect carries our state. Browsers also ask for
// /favicon.ico, and any local process can reach the port.
if r.URL.Path != "/" || q.Get("state") != state {
http.NotFound(w, r)
return
}
res := result{code: q.Get("code")}
if e := q.Get("error"); e != "" {
http.Error(w, e, http.StatusBadRequest)
res = result{err: fmt.Errorf("oauth: %s", e)}
} else {
fmt.Fprintln(w, "mutter is logged in. You can close this tab.")
ch <- result{code: q.Get("code")}
}
// A repeated redirect finds the channel full and is dropped.
select {
case ch <- res:
default:
}
})}
// Serve returns ErrServerClosed once the login finishes.
Expand Down
16 changes: 11 additions & 5 deletions chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,7 @@ func (c *client) threads(ctx context.Context, space string, n int64, pageToken s
continue
}
g.Go(func() error {
var msgs []*chat.Message
err := c.svc.Spaces.Messages.List(space).Filter("thread.name = "+t.name).PageSize(1000).Pages(gctx, func(r *chat.ListMessagesResponse) error {
msgs = append(msgs, r.Messages...)
return nil
})
msgs, err := c.threadMessages(gctx, space, t.name)
if err != nil {
return err
}
Expand All @@ -272,6 +268,16 @@ func (c *client) threads(ctx context.Context, space string, n int64, pageToken s
return out, r.NextPageToken, nil
}

// threadMessages fetches every message in thread, oldest first.
func (c *client) threadMessages(ctx context.Context, space, thread string) ([]*chat.Message, error) {
var msgs []*chat.Message
err := c.svc.Spaces.Messages.List(space).Filter("thread.name = "+thread).PageSize(1000).Pages(ctx, func(r *chat.ListMessagesResponse) error {
msgs = append(msgs, r.Messages...)
return nil
})
return msgs, err
}

// groupThreads groups msgs, oldest first, by thread in order of first
// appearance.
func groupThreads(msgs []*chat.Message) []*thread {
Expand Down
20 changes: 14 additions & 6 deletions format.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,20 @@ func formatText(s string) string {
return b.String()
}

// formatInline repeats each marker until nothing changes, because a match
// consumes the boundary character after it, which the next match needs.
func formatInline(s string) string {
for _, in := range inline {
s = in.re.ReplaceAllStringFunc(s, func(m string) string {
sub := in.re.FindStringSubmatch(m)
return sub[1] + in.style.Render(sub[2]) + sub[3]
})
for {
next := in.re.ReplaceAllStringFunc(s, func(m string) string {
sub := in.re.FindStringSubmatch(m)
return sub[1] + in.style.Render(sub[2]) + sub[3]
})
if next == s {
break
}
s = next
}
}
return s
}
Expand All @@ -111,7 +119,7 @@ func messageBody(m *chat.Message, img func(ref string) string, num *int) string
}
for _, a := range m.Attachment {
*num++
label := dimStyle.Render(fmt.Sprintf("[%d 路 %s]", *num, cmp.Or(a.ContentName, a.ContentType)))
label := dimStyle.Render(fmt.Sprintf("[%d 路 %s]", *num, clean(cmp.Or(a.ContentName, a.ContentType))))
if s := img(imageRef(a)); s != "" {
parts = append(parts, s)
}
Expand All @@ -138,7 +146,7 @@ const maxQuoteLines = 3
// lines so the reply stays the focus.
func quote(q *chat.QuotedMessageMetadata) string {
snap := q.QuotedMessageSnapshot
label := snap.Sender
label := clean(snap.Sender)
if q.QuoteType == "FORWARD" {
label = "Forwarded from " + label
}
Expand Down
1 change: 1 addition & 0 deletions format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func TestFormatText(t *testing.T) {
{"2*3*4", "2*3*4"},
{"* not bold *", "* not bold *"},
{"(*x*)", "(" + b("x") + ")"},
{"*a* *b*", b("a") + " " + b("b")},
{"`*raw*`", c("*raw*")},
{"```\nfn *x*\n```", c("fn *x*")},
}
Expand Down
80 changes: 78 additions & 2 deletions image.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ const (
maxImageBytes = 20 << 20
maxImageCols = 60
maxGIFFrames = 150

// Decoded size limits. A still decodes to 4 bytes per pixel, a GIF frame
// to 1.
maxImagePixels = 1 << 24
maxGIFPixels = 1 << 26
)

// Images use the kitty graphics protocol with Unicode placeholders. The
Expand Down Expand Up @@ -151,13 +156,32 @@ func download(ref string, do func() (*http.Response, error), lay layout) imageMs
if err != nil {
return imageMsg{ref: ref, err: err}
}
if g, err := gif.DecodeAll(bytes.NewReader(data)); err == nil {
// Decoding allocates by the declared size, which a small file can set
// to gigabytes, so it's checked first.
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
log.Printf("image %s: decode: %v", key, err)
return imageMsg{ref: ref, err: err}
}
pixels := cfg.Width * cfg.Height
if pixels == 0 || pixels > maxImagePixels {
return imageMsg{ref: ref, err: fmt.Errorf("image is %dx%d pixels", cfg.Width, cfg.Height)}
}
if format == "gif" {
// Every frame decodes to the full canvas at most, so capping the
// frames bounds the total.
frames := min(maxGIFFrames, maxGIFPixels/pixels)
g, err := gif.DecodeAll(bytes.NewReader(gifPrefix(data, frames)))
if err != nil {
log.Printf("image %s: decode: %v", key, err)
return imageMsg{ref: ref, err: err}
}
msg := decodeGIF(g, lay)
msg.ref = ref
log.Printf("image %s: format=gif frames=%d cells=%dx%d err=%v", key, len(msg.pngs), msg.cols, msg.rows, msg.err)
return msg
}
src, format, err := image.Decode(bytes.NewReader(data))
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
log.Printf("image %s: decode: %v", key, err)
return imageMsg{ref: ref, err: err}
Expand All @@ -172,6 +196,58 @@ func download(ref string, do func() (*http.Response, error), lay layout) imageMs
return imageMsg{ref: ref, pngs: [][]byte{out}, cols: cols, rows: rows}
}

// gifPrefix cuts a GIF after n frames by walking its blocks. A GIF it can't
// walk comes back truncated, so decoding fails instead of running unbounded.
func gifPrefix(data []byte, n int) []byte {
const header = 13 // signature and logical screen descriptor
if len(data) < header {
return data
}
i := header
if data[10]&0x80 != 0 {
i += 3 << (data[10]&7 + 1) // global color table
}
subBlocks := func() bool {
for i < len(data) {
size := int(data[i])
i += 1 + size
if size == 0 {
return true
}
}
return false
}
for frames := 0; i < len(data); {
switch data[i] {
case 0x21: // extension: introducer, label, sub-blocks
i += 2
if !subBlocks() {
return data[:0]
}
case 0x2c: // image descriptor
if frames == n {
return append(data[:i:i], 0x3b)
}
frames++
if i+10 > len(data) {
return data[:0]
}
if flags := data[i+9]; flags&0x80 != 0 {
i += 3 << (flags&7 + 1) // local color table
}
i += 11 // descriptor and LZW minimum code size
if !subBlocks() {
return data[:0]
}
case 0x3b: // trailer
return data[:i+1]
default:
return data[:0]
}
}
return data // no trailer, but every frame was counted
}

// decodeGIF composites each frame onto a canvas following the frame's
// disposal method, because GIF frames are often partial updates.
func decodeGIF(g *gif.GIF, lay layout) imageMsg {
Expand Down
37 changes: 37 additions & 0 deletions image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"image/color"
"image/gif"
"image/png"
"io"
"net/http"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -53,6 +55,41 @@ func TestFormatTextDropsControls(t *testing.T) {
}
}

func TestGIFPrefix(t *testing.T) {
pal := color.Palette{color.Black, color.White}
g := &gif.GIF{Delay: []int{0, 0, 0}}
for range 3 {
g.Image = append(g.Image, image.NewPaletted(image.Rect(0, 0, 2, 2), pal))
}
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, g); err != nil {
t.Fatal(err)
}
for _, n := range []int{1, 2, 3, 5} {
got, err := gif.DecodeAll(bytes.NewReader(gifPrefix(buf.Bytes(), n)))
if err != nil {
t.Fatalf("n=%d: %v", n, err)
}
if want := min(n, 3); len(got.Image) != want {
t.Errorf("n=%d: %d frames, want %d", n, len(got.Image), want)
}
}
if got := gifPrefix([]byte("GIF89a\x01\x00\x01\x00\x00\x00\x00junk"), 1); len(got) != 0 {
t.Errorf("unwalkable GIF kept %d bytes", len(got))
}
}

func TestDownloadRejectsHugeImage(t *testing.T) {
// A header declaring 65535x65535 pixels, which would decode to gigabytes.
data := []byte("GIF89a\xff\xff\xff\xff\x00\x00\x00;")
msg := download("ref", func() (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(bytes.NewReader(data))}, nil
}, layout{8, 16, 60, 20})
if msg.err == nil {
t.Fatal("huge image accepted")
}
}

func TestEncodePNGFlattensPalettedFrame(t *testing.T) {
// An offset paletted frame, as gif.Decode can return.
src := image.NewPaletted(image.Rect(5, 5, 9, 7), color.Palette{color.Transparent, color.White})
Expand Down
4 changes: 4 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ func loadConfig() (config, error) {
if err := json.Unmarshal(b, &cfg); err != nil {
return config{}, fmt.Errorf("%s: %w", path, err)
}
if cfg.ClientID == "" && cfg.ClientSecret == "" {
// A file that only sets the topic keeps the baked-in client.
cfg.ClientID, cfg.ClientSecret = clientID, clientSecret
}
if cfg.ClientID == "" || cfg.ClientSecret == "" {
return config{}, fmt.Errorf("%s: client_id and client_secret are required", path)
}
Expand Down
25 changes: 22 additions & 3 deletions nav.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ type (
space space
name string
gone bool
open bool // open it once known, for /dm
open bool // open it once known, for /dm
msg *chat.Message // handle it once known, for a new space
}
// threadMsg carries every message of a thread, oldest first.
threadMsg struct {
name string
msgs []*chat.Message
}
sectionsMsg map[string]string
)
Expand Down Expand Up @@ -77,13 +83,23 @@ func (m *model) addOlder(msg olderMsg) tea.Cmd {
return m.imgs.fetch(m.ctx, m.c, roots)
}

func (m model) fetchSpace(name string, open bool) tea.Cmd {
func (m model) fetchSpace(name string, open bool, msg *chat.Message) tea.Cmd {
return func() tea.Msg {
s, gone, err := m.c.getSpace(m.ctx, name)
if err != nil {
return errMsg(err)
}
return spaceInfoMsg{space: s, name: name, gone: gone, open: open}
return spaceInfoMsg{space: s, name: name, gone: gone, open: open, msg: msg}
}
}

func (m model) fetchThread(space, name string) tea.Cmd {
return func() tea.Msg {
msgs, err := m.c.threadMessages(m.ctx, space, name)
if err != nil {
return errMsg(err)
}
return threadMsg{name, msgs}
}
}

Expand Down Expand Up @@ -115,6 +131,9 @@ func (m *model) applySpace(msg spaceInfoMsg) tea.Cmd {
if msg.open {
return m.open(i)
}
if msg.msg != nil {
return m.incoming(msg.msg)
}
return nil
}

Expand Down
Loading
Loading