commit 4aa4ccb

delthas  ·  2024-07-18 13:56:35 +0000 UTC
parent e3066fa
Add support for uploading files

Either with a command or a drag and drop of a file.

Also support autocompletion of file paths.
8 files changed,  +258, -5
M app.go
+3, -1
 1@@ -11,7 +11,8 @@ senpai is an IRC client that works best with bouncers:
 2 - no logs are kept,
 3 - history is fetched from the server via [CHATHISTORY],
 4 - networks are fetched from the server via [bouncer-networks],
 5-- messages can be searched in logs via [SEARCH].
 6+- messages can be searched in logs via [SEARCH],
 7+- files can be uploaded via [FILEHOST] (with drag & drop!)
 8 
 9 ## Quick demo
10 
11@@ -102,5 +103,6 @@ Copyright (C) 2021 The senpai Contributors
12 [bouncer-networks]: https://git.sr.ht/~emersion/soju/tree/master/item/doc/ext/bouncer-networks.md
13 [CHATHISTORY]: https://ircv3.net/specs/extensions/chathistory
14 [SEARCH]: https://github.com/ircv3/ircv3-specifications/pull/496
15+[FILEHOST]: https://codeberg.org/emersion/soju/src/branch/master/doc/ext/filehost.md
16 [Libera.Chat]: https://libera.chat/
17 [ml]: https://lists.sr.ht/~delthas/senpai-dev
M app.go
+129, -4
  1@@ -8,10 +8,13 @@ import (
  2 	_ "image/gif"
  3 	_ "image/jpeg"
  4 	_ "image/png"
  5+	"io"
  6+	"mime"
  7 	"net"
  8 	"net/http"
  9 	"os"
 10 	"os/exec"
 11+	"path/filepath"
 12 	"strings"
 13 	"sync"
 14 	"time"
 15@@ -94,10 +97,11 @@ type boundKey struct {
 16 }
 17 
 18 type App struct {
 19-	win      *ui.UI
 20-	sessions map[string]*irc.Session // map of network IDs to their current session
 21-	pasting  bool
 22-	events   chan event
 23+	win              *ui.UI
 24+	sessions         map[string]*irc.Session // map of network IDs to their current session
 25+	pasting          bool
 26+	pastingInputOnly bool // true is pasting started when the editor input was empty
 27+	events           chan event
 28 
 29 	cfg        Config
 30 	highlights []string
 31@@ -120,6 +124,8 @@ type App struct {
 32 
 33 	imageLoading bool
 34 	imageOverlay bool
 35+
 36+	uploadingProgress *float64
 37 }
 38 
 39 func NewApp(cfg Config) (app *App, err error) {
 40@@ -489,8 +495,17 @@ func (app *App) handleUIEvent(ev interface{}) bool {
 41 		app.win.Resize()
 42 	case vaxis.PasteStartEvent:
 43 		app.pasting = true
 44+		app.pastingInputOnly = len(app.win.InputContent()) == 0
 45 	case vaxis.PasteEndEvent:
 46 		app.pasting = false
 47+		if app.pastingInputOnly {
 48+			app.pastingInputOnly = false
 49+
 50+			path := string(app.win.InputContent())
 51+			if _, err := os.Stat(path); err == nil {
 52+				app.win.InputSet(fmt.Sprintf("/upload %v", path))
 53+			}
 54+		}
 55 	case vaxis.Mouse:
 56 		app.handleMouseEvent(ev)
 57 	case vaxis.Key:
 58@@ -512,6 +527,31 @@ func (app *App) handleUIEvent(ev interface{}) bool {
 59 		if ev.Image == nil {
 60 			app.imageLoading = false
 61 		}
 62+	case *events.EventFileUpload:
 63+		if ev.Location != "" {
 64+			app.uploadingProgress = nil
 65+			if len(app.win.InputContent()) == 0 {
 66+				app.win.InputSet(ev.Location)
 67+			} else {
 68+				netID, buffer := app.win.CurrentBuffer()
 69+				app.win.AddLine(netID, buffer, ui.Line{
 70+					At:   time.Now(),
 71+					Head: "--",
 72+					Body: ui.PlainString(fmt.Sprintf("File uploaded at: %v", ev.Location)),
 73+				})
 74+			}
 75+		} else if ev.Error != "" {
 76+			app.uploadingProgress = nil
 77+			netID, buffer := app.win.CurrentBuffer()
 78+			app.win.AddLine(netID, buffer, ui.Line{
 79+				At:        time.Now(),
 80+				Head:      "!!",
 81+				HeadColor: ui.ColorRed,
 82+				Body:      ui.PlainString(fmt.Sprintf("File upload failed: %v", ev.Error)),
 83+			})
 84+		} else {
 85+			app.uploadingProgress = &ev.Progress
 86+		}
 87 	default:
 88 		// TODO: missing event types
 89 	}
 90@@ -908,6 +948,70 @@ func (app *App) handleLinkEvent(ev *events.EventClickLink) {
 91 	}()
 92 }
 93 
 94+func (app *App) upload(url string, f *os.File, size int64) (string, error) {
 95+	defer f.Close()
 96+	c := http.Client{
 97+		Timeout: 30 * time.Second,
 98+	}
 99+	r := ReadProgress{
100+		Reader: f,
101+		period: 250 * time.Millisecond,
102+		f: func(n int64) {
103+			app.events <- event{
104+				src: "*",
105+				content: &events.EventFileUpload{
106+					Progress: float64(n) / float64(size),
107+				},
108+			}
109+		},
110+	}
111+	req, err := http.NewRequest("POST", url, &r)
112+	if err != nil {
113+		return "", fmt.Errorf("creating upload request: %v", err)
114+	}
115+	if app.cfg.Password != nil {
116+		req.SetBasicAuth(app.cfg.User, *app.cfg.Password)
117+	}
118+	req.Header.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
119+		"filename": filepath.Base(f.Name()),
120+	}))
121+	res, err := c.Do(req)
122+	if err != nil {
123+		return "", fmt.Errorf("uploading: %v", err)
124+	}
125+	if res.StatusCode != http.StatusCreated {
126+		return "", fmt.Errorf("uploading: unexpected status code: %d", res.StatusCode)
127+	}
128+	location, err := res.Location()
129+	if err != nil {
130+		return "", fmt.Errorf("uploading: reading file URL: %v", err)
131+	}
132+	return location.String(), nil
133+}
134+
135+func (app *App) handleUpload(url string, f *os.File, size int64) {
136+	var progress float64 = 0
137+	app.uploadingProgress = &progress
138+	go func() {
139+		location, err := app.upload(url, f, size)
140+		if err != nil {
141+			app.events <- event{
142+				src: "*",
143+				content: &events.EventFileUpload{
144+					Error: err.Error(),
145+				},
146+			}
147+		} else {
148+			app.events <- event{
149+				src: "*",
150+				content: &events.EventFileUpload{
151+					Location: location,
152+				},
153+			}
154+		}
155+	}()
156+}
157+
158 // maybeRequestHistory is a wrapper around irc.Session.RequestHistory to only request
159 // history when needed.
160 func (app *App) maybeRequestHistory() {
161@@ -1469,6 +1573,7 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
162 		cs = app.completionsChannelTopic(cs, cursorIdx, text)
163 		cs = app.completionsChannelMembers(cs, cursorIdx, text)
164 	}
165+	cs = app.completionsUpload(cs, cursorIdx, text)
166 	cs = app.completionsMsg(cs, cursorIdx, text)
167 	cs = app.completionsCommands(cs, cursorIdx, text)
168 	cs = app.completionsEmoji(cs, cursorIdx, text)
169@@ -1961,3 +2066,23 @@ func keyMatches(k vaxis.Key, r rune, mods vaxis.ModifierMask) bool {
170 	}
171 	return false
172 }
173+
174+type ReadProgress struct {
175+	io.Reader
176+	period time.Duration
177+	f      func(int64)
178+
179+	n    int64
180+	last time.Time
181+}
182+
183+func (r *ReadProgress) Read(buf []byte) (int, error) {
184+	n, err := r.Reader.Read(buf)
185+	r.n += int64(n)
186+	now := time.Now()
187+	if now.Sub(r.last) > r.period {
188+		r.last = now
189+		r.f(r.n)
190+	}
191+	return n, err
192+}
+40, -0
 1@@ -4,6 +4,7 @@ import (
 2 	"errors"
 3 	"fmt"
 4 	"net/url"
 5+	"os"
 6 	"sort"
 7 	"strconv"
 8 	"strings"
 9@@ -72,6 +73,14 @@ func init() {
10 			Desc:   "send the current song that is being played on the system",
11 			Handle: commandDoNP,
12 		},
13+		"UPLOAD": {
14+			AllowHome: true,
15+			MinArgs:   1,
16+			MaxArgs:   1,
17+			Usage:     "<file path>",
18+			Desc:      "upload a local file to the bouncer",
19+			Handle:    commandDoUpload,
20+		},
21 		"MSG": {
22 			AllowHome: true,
23 			MinArgs:   2,
24@@ -484,6 +493,37 @@ func commandDoNP(app *App, args []string) (err error) {
25 	return commandDoMe(app, []string{fmt.Sprintf("np: %s", song)})
26 }
27 
28+func commandDoUpload(app *App, args []string) (err error) {
29+	if app.cfg.Transient || !app.cfg.LocalIntegrations {
30+		return fmt.Errorf("usage of UPLOAD is disabled")
31+	}
32+	s := app.CurrentSession()
33+	if s == nil {
34+		return errOffline
35+	}
36+	upload := s.UploadURL()
37+	if upload == "" {
38+		return fmt.Errorf("file upload is not supported on this server; try using soju and enabling file upload")
39+	}
40+	path := args[0]
41+
42+	fi, err := os.Stat(path)
43+	if err != nil {
44+		return fmt.Errorf("opening file: %v", err)
45+	}
46+	if fi.Size() > 50*1024*1024 {
47+		// Best-effort limit, taking from current soju
48+		return fmt.Errorf("file too large: maximum 50MB per file")
49+	}
50+	f, err := os.Open(path)
51+	if err != nil {
52+		return fmt.Errorf("opening file: %v", err)
53+	}
54+
55+	app.handleUpload(upload, f, fi.Size())
56+	return nil
57+}
58+
59 func commandDoMsg(app *App, args []string) (err error) {
60 	target := args[0]
61 	content := args[1]
+64, -0
 1@@ -2,6 +2,8 @@ package senpai
 2 
 3 import (
 4 	"fmt"
 5+	"os"
 6+	"path/filepath"
 7 	"strings"
 8 
 9 	"git.sr.ht/~delthas/senpai/ui"
10@@ -107,6 +109,68 @@ func (app *App) completionsMsg(cs []ui.Completion, cursorIdx int, text []rune) [
11 	return cs
12 }
13 
14+func (app *App) completionsUpload(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
15+	if !hasPrefix(text, []rune("/upload ")) {
16+		return cs
17+	}
18+	if app.cfg.Transient || !app.cfg.LocalIntegrations {
19+		return cs
20+	}
21+	_, path, ok := strings.Cut(string(text[:cursorIdx]), " ")
22+	if !ok {
23+		return cs
24+	}
25+
26+	dirPath := ""
27+	dirPrefix := ""
28+	if path == "" {
29+		if filepath.Separator != '/' {
30+			return cs
31+		}
32+		dirPath = "/"
33+	} else if strings.HasSuffix(path, string(filepath.Separator)) {
34+		dirPath = path
35+	} else {
36+		dirPath = filepath.Dir(path)
37+		dirPrefix = filepath.Base(path)
38+	}
39+	dir, err := os.ReadDir(dirPath)
40+	if err != nil {
41+		return cs
42+	}
43+
44+	for _, e := range dir {
45+		if !strings.HasPrefix(e.Name(), dirPrefix) {
46+			continue
47+		}
48+		name := e.Name()
49+		var isDir bool
50+		if e.IsDir() {
51+			isDir = true
52+		} else if e.Type() == os.ModeSymlink {
53+			if fi, err := os.Stat(filepath.Join(dirPath, name)); err == nil && fi.IsDir() {
54+				isDir = true
55+			}
56+		}
57+		if isDir {
58+			name += string(filepath.Separator)
59+		}
60+		if path == "" {
61+			name = "/" + name
62+		}
63+		if name == dirPrefix {
64+			continue
65+		}
66+		cs = append(cs, ui.Completion{
67+			StartIdx:  cursorIdx - len([]rune(dirPrefix)),
68+			EndIdx:    cursorIdx,
69+			Text:      []rune(string(text[:cursorIdx]) + name[len(dirPrefix):] + string(text[cursorIdx:])),
70+			CursorIdx: cursorIdx + len([]rune(name)) - len([]rune(dirPrefix)),
71+		})
72+	}
73+	return cs
74+}
75+
76 func (app *App) completionsCommands(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
77 	if !hasPrefix(text, []rune("/")) {
78 		return cs
+3, -0
 1@@ -203,6 +203,9 @@ _name_ is matched case-insensitively.  It can be one of the following:
 2 	Send the current song that is being played on the system. Uses DBus/MPRIS
 3 	internally.
 4 
 5+*UPLOAD* <file path>
 6+	Upload a local file to the bouncer.
 7+
 8 *QUOTE* <raw message>
 9 	Send _raw message_ verbatim.
10 
+6, -0
 1@@ -34,3 +34,9 @@ type EventClickLink struct {
 2 type EventImageLoaded struct {
 3 	Image image.Image // nil if error
 4 }
 5+
 6+type EventFileUpload struct {
 7+	Progress float64
 8+	Location string
 9+	Error    string
10+}
+8, -0
 1@@ -139,6 +139,7 @@ type Session struct {
 2 	prefixModes   string
 3 	monitor       bool
 4 	whox          bool
 5+	upload        string
 6 
 7 	users          map[string]*User        // known users.
 8 	channels       map[string]Channel      // joined channels.
 9@@ -233,6 +234,11 @@ func (s *Session) BouncerService() string {
10 	return ""
11 }
12 
13+// UploadURL returns the URL to which files can be uploaded according to the FILEHOST specification.
14+func (s *Session) UploadURL() string {
15+	return s.upload
16+}
17+
18 func (s *Session) Nick() string {
19 	return s.nick
20 }
21@@ -2082,6 +2088,8 @@ func (s *Session) updateFeatures(features []string) {
22 			s.prefixSymbols = value[numPrefixes+2:]
23 		case "WHOX":
24 			s.whox = true
25+		case "SOJU.IM/FILEHOST":
26+			s.upload = value
27 		}
28 	}
29 }
+5, -0
 1@@ -1,6 +1,7 @@
 2 package senpai
 3 
 4 import (
 5+	"fmt"
 6 	"strconv"
 7 	"strings"
 8 	"time"
 9@@ -50,6 +51,10 @@ func (app *App) setStatus() {
10 		app.imageLoading = false
11 		app.imageOverlay = true
12 	}
13+	if app.uploadingProgress != nil {
14+		app.win.SetStatus(fmt.Sprintf("Uploading file (%02.1f%%)...", *app.uploadingProgress*100))
15+		return
16+	}
17 	if app.imageLoading {
18 		app.win.SetStatus("Loading image...")
19 		return