From 949ddb62b9d6244534e409488c02137d01bdd475 Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sun, 17 Mar 2024 12:12:13 +1000 Subject: [PATCH] chunk files --- go.mod | 2 +- go.sum | 2 + internal/chunk/directory.go | 11 ++- internal/chunk/file.go | 2 +- internal/viewer/handlers.go | 28 +++++++ internal/viewer/middleware.go | 24 ++++++ internal/viewer/renderer/slack.go | 29 +++++-- .../viewer/renderer/slack_fixtures_test.go | 81 +++++++++++++++++++ internal/viewer/renderer/slack_test.go | 32 ++++++++ internal/viewer/source/chunkdir.go | 6 ++ internal/viewer/source/dump.go | 4 + internal/viewer/source/export.go | 34 ++------ internal/viewer/source/source.go | 31 +++++++ internal/viewer/template.go | 29 ++++++- internal/viewer/templates/attachment.html | 3 +- internal/viewer/templates/index.html | 11 ++- internal/viewer/viewer.go | 13 ++- 17 files changed, 298 insertions(+), 44 deletions(-) create mode 100644 internal/viewer/middleware.go diff --git a/go.mod b/go.mod index 39f1fcb0..6663b05a 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/rusq/chttp v1.0.2 github.com/rusq/dlog v1.4.0 github.com/rusq/encio v0.1.0 - github.com/rusq/fsadapter v1.0.1 + github.com/rusq/fsadapter v1.0.2 github.com/rusq/osenv/v2 v2.0.1 github.com/rusq/slack v0.9.6-0.20240211120639-93c163940e55 github.com/rusq/slackauth v0.1.1 diff --git a/go.sum b/go.sum index 8516d2eb..b1a1b484 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/rusq/encio v0.1.0 h1:DauNaVtIf79kILExhMGIsE5svYwPnDSksdYP0oVVcr8= github.com/rusq/encio v0.1.0/go.mod h1:AP3lDpo/BkcHcOMNduBlZdd0sbwhruq6+NZtYm5Mxb0= github.com/rusq/fsadapter v1.0.1 h1:ADFlyApviYrBWo6N2xm4fh/gFFTy6XoLBU1m7Tvf05k= github.com/rusq/fsadapter v1.0.1/go.mod h1:56enDqgnY1Mu+brFsVoA3WW4VbKdtjflR7qz/7Rab1E= +github.com/rusq/fsadapter v1.0.2 h1:T+hG8nvA4WlM5oLRcUMEPEOdmDWEd9wmD2oNpztwz90= +github.com/rusq/fsadapter v1.0.2/go.mod h1:oqHuzWZKhum7JBLCUi/mkyrxngtfemuJB0HgEJ6595A= github.com/rusq/osenv/v2 v2.0.1 h1:1LtNt8VNV/W86wb38Hyu5W3Rwqt/F1JNRGE+8GRu09o= github.com/rusq/osenv/v2 v2.0.1/go.mod h1:+wJBSisjNZpfoD961JzqjaM+PtaqSusO3b4oVJi7TFY= github.com/rusq/secure v0.0.4 h1:svpiZHfHnx89eEDCCFI9OXG1Y8hL9kUWUG6fJbrWUOI= diff --git a/internal/chunk/directory.go b/internal/chunk/directory.go index 83d51471..8ae37693 100644 --- a/internal/chunk/directory.go +++ b/internal/chunk/directory.go @@ -25,6 +25,8 @@ const ( FWorkspace FileID = "workspace" ) +const uploadsDir = "__uploads" // for serving files + // Directory is an abstraction over the directory with chunk files. It // provides a way to write chunk files and read channels, users and messages // across many the chunk files. All functions that require a name, except @@ -33,7 +35,9 @@ const ( // file with the extension. All files created by this package will be // compressed with GZIP, unless stated otherwise. type Directory struct { - dir string // path to a physical directory on the filesystem + // dir is a path to a physical directory on the filesystem with chunks and + // uploads. + dir string cache dcache wantCache bool @@ -349,3 +353,8 @@ func cachedFromReader(wf osext.ReadSeekCloseNamer, wantCache bool) (*File, error } return cf, nil } + +// File returns the file with the given id and name. +func (d *Directory) File(id string, name string) (fs.File, error) { + return os.Open(filepath.Join(d.dir, uploadsDir, id, name)) +} diff --git a/internal/chunk/file.go b/internal/chunk/file.go index 2b9bb625..c439979d 100644 --- a/internal/chunk/file.go +++ b/internal/chunk/file.go @@ -343,7 +343,7 @@ func (f *File) ChannelUsers(channelID string) ([]string, error) { }) } -func (f *File) channelInfo(channelID string, thread bool) (*slack.Channel, error) { +func (f *File) channelInfo(channelID string, _ bool) (*slack.Channel, error) { chunk, err := f.firstChunkForID(channelInfoID(channelID)) if err != nil { return nil, err diff --git a/internal/viewer/handlers.go b/internal/viewer/handlers.go index 10745c10..b82fc7c4 100644 --- a/internal/viewer/handlers.go +++ b/internal/viewer/handlers.go @@ -1,7 +1,10 @@ package viewer import ( + "errors" + "io" "net/http" + "os" "path/filepath" "github.com/davecgh/go-spew/spew" @@ -123,6 +126,31 @@ func (v *Viewer) threadHandler(w http.ResponseWriter, r *http.Request, id string } func (v *Viewer) fileHandler(w http.ResponseWriter, r *http.Request) { + var ( + id = r.PathValue("id") + filename = r.PathValue("filename") + ) + if id == "" || filename == "" { + http.NotFound(w, r) + return + } + f, err := v.src.File(id, filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.NotFound(w, r) + return + } + v.lg.Printf("error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fi, err := f.Stat() + if err != nil { + v.lg.Printf("error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.ServeContent(w, r, filename, fi.ModTime(), f.(io.ReadSeeker)) // TODO: hack } func (v *Viewer) userHandler(w http.ResponseWriter, r *http.Request) { diff --git a/internal/viewer/middleware.go b/internal/viewer/middleware.go new file mode 100644 index 00000000..544cc83c --- /dev/null +++ b/internal/viewer/middleware.go @@ -0,0 +1,24 @@ +package viewer + +import ( + "fmt" + "net/http" + "time" +) + +// cacheMware is a middleware that sets cache control headers. +// [Mozilla reference]. +// +// [Mozilla reference]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control +func cacheMwareFunc(t time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + val := "no-cache, no-store, must-revalidate" + if t > 0 { + val = fmt.Sprintf("max-age=%d", int(t.Seconds())) + } + w.Header().Set("Cache-Control", val) + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/viewer/renderer/slack.go b/internal/viewer/renderer/slack.go index 07d76b5a..68219776 100644 --- a/internal/viewer/renderer/slack.go +++ b/internal/viewer/renderer/slack.go @@ -50,12 +50,14 @@ func (*Slack) RenderText(ctx context.Context, s string) (v template.HTML) { } func (s *Slack) Render(ctx context.Context, m *slack.Message) (v template.HTML) { + var buf strings.Builder + if len(m.Blocks.BlockSet) == 0 { - return s.RenderText(ctx, m.Text) + s.RenderText(ctx, m.Text) + } else { + s.renderBlocks(ctx, &buf, m.Timestamp, m.Blocks.BlockSet) } - - var buf strings.Builder - s.renderBlocks(ctx, &buf, m.Timestamp, m.Blocks.BlockSet) + s.renderFiles(ctx, &buf, m.Timestamp, m.Files) s.renderAttachments(ctx, &buf, m.Timestamp, m.Attachments) return template.HTML(buf.String()) @@ -84,11 +86,22 @@ func (s *Slack) renderBlocks(ctx context.Context, buf *strings.Builder, msgTS st } func (s *Slack) renderAttachments(ctx context.Context, buf *strings.Builder, msgTS string, attachments []slack.Attachment) { - attrMsgID := slog.String("message_ts", msgTS) for _, a := range attachments { - if err := s.tmpl.ExecuteTemplate(buf, "attachment.html", a); err != nil { - slog.ErrorContext(ctx, "error rendering attachment", "error", err, attrMsgID) - } + s.renderAttachment(ctx, buf, msgTS, a) + } +} + +func (s *Slack) renderAttachment(ctx context.Context, buf *strings.Builder, msgTS string, a slack.Attachment) { + attrMsgID := slog.String("message_ts", msgTS) + if err := s.tmpl.ExecuteTemplate(buf, "attachment.html", a); err != nil { + slog.ErrorContext(ctx, "error rendering attachment", "error", err, attrMsgID) + } +} + +func (s *Slack) renderFiles(ctx context.Context, buf *strings.Builder, msgTS string, files []slack.File) { + attrMsgID := slog.String("message_ts", msgTS) + if err := s.tmpl.ExecuteTemplate(buf, "file.html", files); err != nil { + slog.ErrorContext(ctx, "error rendering files", "error", err, attrMsgID) } } diff --git a/internal/viewer/renderer/slack_fixtures_test.go b/internal/viewer/renderer/slack_fixtures_test.go index f0ec0f15..3dd6842b 100644 --- a/internal/viewer/renderer/slack_fixtures_test.go +++ b/internal/viewer/renderer/slack_fixtures_test.go @@ -188,3 +188,84 @@ const ( ] }` ) + +// Attachments +const ( + fxtrAttYoutube = `{ + "fallback": "YouTube Video: Microsoft Account Takeover: Combination of Subdomain Takeovers and Open Redirection Vulnerabilities", + "id": 1, + "author_name": "VULLNERABILITY", + "author_link": "https://www.youtube.com/channel/UClWkD38yogV4fRktm6Kb_2w", + "title": "Microsoft Account Takeover: Combination of Subdomain Takeovers and Open Redirection Vulnerabilities", + "title_link": "https://youtu.be/Jg3mkLm2K2g", + "thumb_url": "https://i.ytimg.com/vi/Jg3mkLm2K2g/hqdefault.jpg", + "service_name": "YouTube", + "service_icon": "https://a.slack-edge.com/80588/img/unfurl_icons/youtube.png", + "from_url": "https://youtu.be/Jg3mkLm2K2g", + "original_url": "https://youtu.be/Jg3mkLm2K2g", + "blocks": null + }` + fxtrAttTwitter = `{ + "fallback": "\u003chttps://twitter.com/edwardodell|@edwardodell\u003e: NEVER LEAVE, NEVER PAY", + "id": 1, + "author_name": "Edward Odell", + "author_subname": "@edwardodell", + "author_link": "https://twitter.com/edwardodell/status/1591044196705927168", + "author_icon": "https://pbs.twimg.com/profile_images/1590674641458167808/Z4vACFd0_normal.jpg", + "text": "NEVER LEAVE, NEVER PAY", + "image_url": "https://pbs.twimg.com/media/FhSGQsVXwAUpyU5.jpg", + "service_name": "twitter", + "from_url": "https://twitter.com/edwardodell/status/1591044196705927168?s=46\u0026amp;t=w-i11UUFTIWOtvEWpF2hpQ", + "original_url": "https://twitter.com/edwardodell/status/1591044196705927168?s=46\u0026amp;t=w-i11UUFTIWOtvEWpF2hpQ", + "blocks": null, + "footer": "Twitter", + "footer_icon": "https://a.slack-edge.com/80588/img/services/twitter_pixel_snapped_32.png", + "ts": 1668169471 + }` + fxtrAttTwitterX = `{ + "fallback": "X (formerly Twitter): Elon Musk Junior :flag-ke: (@ElonMursq) on X", + "id": 1, + "title": "Elon Musk Junior :flag-ke: (@ElonMursq) on X", + "title_link": "https://twitter.com/ElonMursq", + "text": "Please help me reconnect with my dad @ElonMusk.\n\nMy BTC wallet address: 1FM6odFCta6gKwo2ib9jJ2JCEJgQixLoc2", + "thumb_url": "https://pbs.twimg.com/profile_images/1767278440892289024/WXKK1Oa-_200x200.jpg", + "service_name": "X (formerly Twitter)", + "service_icon": "http://abs.twimg.com/favicons/twitter.3.ico", + "from_url": "https://twitter.com/ElonMursq", + "original_url": "https://twitter.com/ElonMursq", + "blocks": null + }` + fxtrAttNzHerald = `{ + "fallback": "NZ Herald: NZ Herald: Latest NZ news, plus World, Sport, Business and more - NZ Herald", + "id": 3, + "title": "NZ Herald: Latest NZ news, plus World, Sport, Business and more - NZ Herald", + "title_link": "https://www.nzherald.co.nz/", + "text": "Get the latest breaking news, analysis and opinion from NZ and around the world, including politics, business, sport, entertainment, travel and more, with NZ Herald", + "thumb_url": "https://www.nzherald.co.nz/pf/resources/images/fallback-promo-image.png?d=744", + "service_name": "NZ Herald", + "service_icon": "https://www.nzherald.co.nz/pf/resources/images/favicons/apple-touch-icon-57x57-precomposed.png?d=744", + "from_url": "https://www.nzherald.co.nz/", + "original_url": "https://www.nzherald.co.nz/", + "blocks": null + }` + fxtrAttImage = `{ + "fallback": "1200x1200px image", + "id": 2, + "image_url": "https://pbs.twimg.com/media/FhTXW4bWAA4jqXk.jpg", + "from_url": "https://twitter.com/rafaelshimunov/status/1591133819918114816?s=46\u0026amp;t=w-i11UUFTIWOtvEWpF2hpQ", + "blocks": null + }` + fxtrAttBBC = `{ + "fallback": "BBC News: Elon Musk: Judge blocks 'unfathomable' $56bn Tesla pay deal", + "id": 1, + "title": "Elon Musk: Judge blocks 'unfathomable' $56bn Tesla pay deal", + "title_link": "https://www.bbc.co.uk/news/business-68150306", + "text": "The lawsuit was filed by a shareholder who argued that it was an inappropriate overpayment.", + "image_url": "https://ichef.bbci.co.uk/news/1024/branded_news/F474/production/_132508526_gettyimages-1963458442.jpg", + "service_name": "BBC News", + "service_icon": "https://www.bbc.co.uk/favicon.ico", + "from_url": "https://www.bbc.co.uk/news/business-68150306", + "original_url": "https://www.bbc.co.uk/news/business-68150306", + "blocks": null + }` +) diff --git a/internal/viewer/renderer/slack_test.go b/internal/viewer/renderer/slack_test.go index 8b5d1fbd..08236ed6 100644 --- a/internal/viewer/renderer/slack_test.go +++ b/internal/viewer/renderer/slack_test.go @@ -4,6 +4,7 @@ import ( "context" "html/template" "reflect" + "strings" "testing" "github.com/rusq/slack" @@ -46,3 +47,34 @@ func TestSlack_Render(t *testing.T) { }) } } + +func TestSlack_renderAttachment(t *testing.T) { + type fields struct { + tmpl *template.Template + uu map[string]slack.User + cc map[string]slack.Channel + } + type args struct { + ctx context.Context + buf *strings.Builder + msgTS string + a slack.Attachment + } + tests := []struct { + name string + fields fields + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Slack{ + tmpl: tt.fields.tmpl, + uu: tt.fields.uu, + cc: tt.fields.cc, + } + s.renderAttachment(tt.args.ctx, tt.args.buf, tt.args.msgTS, tt.args.a) + }) + } +} diff --git a/internal/viewer/source/chunkdir.go b/internal/viewer/source/chunkdir.go index 052f4c14..be48576e 100644 --- a/internal/viewer/source/chunkdir.go +++ b/internal/viewer/source/chunkdir.go @@ -1,6 +1,8 @@ package source import ( + "io/fs" + "github.com/rusq/slack" "github.com/rusq/slackdump/v3/internal/chunk" ) @@ -64,3 +66,7 @@ func (c *ChunkDir) Type() string { func (c *ChunkDir) Users() ([]slack.User, error) { return c.d.Users() } + +func (c *ChunkDir) File(fileID string, filename string) (f fs.File, err error) { + return c.d.File(fileID, filename) +} diff --git a/internal/viewer/source/dump.go b/internal/viewer/source/dump.go index 79ce4453..de2066db 100644 --- a/internal/viewer/source/dump.go +++ b/internal/viewer/source/dump.go @@ -130,3 +130,7 @@ func (d Dump) ChannelInfo(channelID string) (*slack.Channel, error) { } return nil, fs.ErrNotExist } + +func (d Dump) File(id string, filename string) (f fs.File, err error) { + panic("not implemented") +} diff --git a/internal/viewer/source/export.go b/internal/viewer/source/export.go index b93a6be1..84e2e81a 100644 --- a/internal/viewer/source/export.go +++ b/internal/viewer/source/export.go @@ -1,7 +1,6 @@ package source import ( - "encoding/json" "fmt" "io/fs" "path" @@ -37,7 +36,12 @@ func NewExport(fsys fs.FS, name string) (*Export, error) { } func (e *Export) Channels() ([]slack.Channel, error) { - return unmarshal[[]slack.Channel](e.fs, "channels.json") + cc, err := unmarshal[[]slack.Channel](e.fs, "channels.json") + if err != nil { + return nil, err + } + // TODO: check dms.json and groups.json + return cc, nil } func (e *Export) Users() ([]slack.User, error) { @@ -112,28 +116,6 @@ func (e *Export) ChannelInfo(channelID string) (*slack.Channel, error) { return nil, fmt.Errorf("%s: %s", "channel not found", channelID) } -func unmarshalOne[T any](fsys fs.FS, name string) (T, error) { - var v T - f, err := fsys.Open(name) - if err != nil { - return v, err - } - defer f.Close() - if err := json.NewDecoder(f).Decode(&v); err != nil { - return v, err - } - return v, nil -} - -func unmarshal[T ~[]S, S any](fsys fs.FS, name string) (T, error) { - f, err := fsys.Open(name) - if err != nil { - return nil, err - } - defer f.Close() - var v T - if err := json.NewDecoder(f).Decode(&v); err != nil { - return nil, err - } - return v, nil +func (e *Export) File(id string, name string) (fs.File, error) { + panic("not implemented") } diff --git a/internal/viewer/source/source.go b/internal/viewer/source/source.go index d150341c..eeebc955 100644 --- a/internal/viewer/source/source.go +++ b/internal/viewer/source/source.go @@ -1 +1,32 @@ package source + +import ( + "encoding/json" + "io/fs" +) + +func unmarshalOne[T any](fsys fs.FS, name string) (T, error) { + var v T + f, err := fsys.Open(name) + if err != nil { + return v, err + } + defer f.Close() + if err := json.NewDecoder(f).Decode(&v); err != nil { + return v, err + } + return v, nil +} + +func unmarshal[T ~[]S, S any](fsys fs.FS, name string) (T, error) { + f, err := fsys.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + var v T + if err := json.NewDecoder(f).Decode(&v); err != nil { + return nil, err + } + return v, nil +} diff --git a/internal/viewer/template.go b/internal/viewer/template.go index c1667b31..46b09801 100644 --- a/internal/viewer/template.go +++ b/internal/viewer/template.go @@ -6,6 +6,8 @@ import ( "encoding/json" "html/template" "log/slog" + "mime" + "strings" "time" "github.com/rusq/slack" @@ -27,6 +29,7 @@ func initTemplates(v *Viewer) { "rendertext": func(s string) template.HTML { return v.r.RenderText(context.Background(), s) }, // render message text "render": func(m *slack.Message) template.HTML { return v.r.Render(context.Background(), m) }, // render message "is_thread_start": st.IsThreadStart, + "mimetype": mimetype, }, ).ParseFS(fsys, "templates/*.html")) v.tmpl = tmpl @@ -41,10 +44,18 @@ func localtime(ts string) string { } func epoch(ts json.Number) string { + if ts == "" { + return "" + } t, err := ts.Int64() if err != nil { - slog.Debug("epoch: %v", err, "ts", ts) - return ts.String() + slog.Debug("epoch Int64 error, trying float", "err", err, "ts", ts) + tf, err := ts.Float64() + if err != nil { + slog.Debug("epoch Float64 error", "err", err, "ts", ts) + return ts.String() + } + t = int64(tf) } return time.Unix(t, 0).Local().Format(time.DateTime) } @@ -91,3 +102,17 @@ func (v *Viewer) username(m *slack.Message) string { func isAppMsg(m *slack.Message) bool { return msgsender(m) == sApp } + +func mimetype(mt string) string { + mm, _, err := mime.ParseMediaType(mt) + if err != nil || mt == "" { + slog.Debug("isImage", "err", err, "mimetype", mt) + return "application" + } + slog.Debug("isImage", "t", mm, "mimetype", mt) + t, _, found := strings.Cut(mm, "/") + if !found { + return "application" + } + return t +} diff --git a/internal/viewer/templates/attachment.html b/internal/viewer/templates/attachment.html index 58947282..2e7bc453 100644 --- a/internal/viewer/templates/attachment.html +++ b/internal/viewer/templates/attachment.html @@ -9,7 +9,8 @@

{{ .Text }}

{{ if .ImageURL }} diff --git a/internal/viewer/templates/index.html b/internal/viewer/templates/index.html index 444979fe..ba9f24d6 100644 --- a/internal/viewer/templates/index.html +++ b/internal/viewer/templates/index.html @@ -133,7 +133,7 @@

Thread: {{ .ThreadID }}

{{ time .Timestamp }}
-

{{/* .Text | rendertext */}}{{ render . }}

+

{{ render . }}

{{ end }} @@ -221,12 +221,19 @@

Thread: {{ .ThreadID }}

.slack-attachment .icon { max-width: 16px; + max-height: 16px; } .slack-attachment .attachment-image { - max-width: 64px; + max-height: 136px; + max-width: 136px; border-radius: 5px; } + .slack-files img { + max-width: 320px; + max-height: 320px; + } + .grey { color: #777; } diff --git a/internal/viewer/viewer.go b/internal/viewer/viewer.go index 0ac44cf3..ff6f2c6d 100644 --- a/internal/viewer/viewer.go +++ b/internal/viewer/viewer.go @@ -5,8 +5,11 @@ import ( "context" "errors" "html/template" + "io/fs" "log/slog" "net/http" + "os" + "time" "github.com/go-chi/chi/v5/middleware" "github.com/rusq/slack" @@ -16,7 +19,7 @@ import ( "github.com/rusq/slackdump/v3/logger" ) -const debug = true +var debug = os.Getenv("DEBUG") != "" func init() { slog.SetLogLoggerLevel(slog.LevelDebug) @@ -54,8 +57,14 @@ type Sourcer interface { // ChannelInfo should return the channel information for the given channel // id. ChannelInfo(channelID string) (*slack.Channel, error) + // File should return the [io.ReadCloser] for the given file ID. + File(fileID string, filename string) (fs.File, error) } +const ( + hour = 60 * time.Minute +) + // New creates new viewer instance. Once [Viewer.ListenAndServe] is called, the // viewer will start serving the web interface on the given address. The // address should be in the form of ":8080". The viewer will use the given @@ -99,8 +108,8 @@ func New(ctx context.Context, addr string, r Sourcer) (*Viewer, error) { mux.HandleFunc("/archives/{id}", v.newFileHandler(v.channelHandler)) // https: //ora600.slack.com/archives/DHMAB25DY/p1710063528879959 mux.HandleFunc("/archives/{id}/{ts}", v.newFileHandler(v.threadHandler)) - mux.HandleFunc("/files/{id}", v.fileHandler) mux.HandleFunc("/team/{user_id}", v.userHandler) + mux.Handle("/slackdump/file/{id}/{filename}", cacheMwareFunc(3*hour)(http.HandlerFunc(v.fileHandler))) v.srv = &http.Server{ Addr: addr, Handler: middleware.Logger(mux),