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