From 2f421fe704447643a0a5325f1c6fc2b9790552a0 Mon Sep 17 00:00:00 2001 From: Dylan Donnell Date: Tue, 10 Mar 2026 00:57:59 +0000 Subject: [PATCH] support auth headers for image downloads, allow overwriting link handler --- view_image.v | 73 ++++++++++++++++++++++++++++++++++++++++++---------- view_rtf.v | 8 +++++- view_state.v | 26 ++++++++++++------- window_api.v | 16 ++++++++++++ 4 files changed, 99 insertions(+), 24 deletions(-) diff --git a/view_image.v b/view_image.v index d515a59..9e3cba3 100644 --- a/view_image.v +++ b/view_image.v @@ -58,7 +58,13 @@ fn (mut iv ImageView) generate_layout(mut window Window) Layout { mut downloads := state_map[string, i64](mut window, ns_active_downloads, cap_moderate) if !downloads.contains(iv.src) { downloads.set(iv.src, time.now().unix()) - spawn download_image(iv.src, base_path, mut window) + // Resolve optional auth header at dispatch time (main thread) + auth_header := if f := window.view_state.image_auth_header_fn { + f(iv.src) + } else { + '' + } + spawn download_image(iv.src, base_path, auth_header, mut window) } mut layout := Layout{ shape: &Shape{ @@ -125,14 +131,24 @@ struct ImageFetchResult { // download_image downloads a remote image to a local cache path. // base_path has no extension; extension is determined from // Content-Type header. Validates size (<50MB) and type (image/*). -fn download_image(url string, base_path string, mut w Window) { - spawn fn [url, base_path, mut w] () { +// auth_header, when non-empty, is sent as the Authorization header. +fn download_image(url string, base_path string, auth_header string, mut w Window) { + spawn fn [url, base_path, auth_header, mut w] () { ch := chan ImageFetchResult{} - spawn fn [url, base_path, ch] () { + spawn fn [url, base_path, auth_header, ch] () { max_size := i64(50 * 1024 * 1024) - head := http.head(url) or { + // Use a request with optional auth so restricted images (e.g. + // Zulip /user_uploads/) are accessible. + mut head_req := http.Request{ + method: .head + url: url + } + if auth_header.len > 0 { + head_req.add_header(.authorization, auth_header) + } + head := head_req.do() or { ch <- ImageFetchResult{ err_msg: 'Failed to fetch image headers for ${url}: ${err}' is_err: true @@ -150,9 +166,10 @@ fn download_image(url string, base_path string, mut w Window) { return } - // Validate content type - ct := head.header.get(.content_type) or { '' } - if !ct.starts_with('image/') { + // Validate content type — some servers omit Content-Type on HEAD; + // allow an empty type and rely on the GET response below. + ct_head := head.header.get(.content_type) or { '' } + if ct_head.len > 0 && !ct_head.starts_with('image/') { ch <- ImageFetchResult{ err_msg: 'Invalid content type for image (expected image/*): ${url}' is_err: true @@ -160,17 +177,47 @@ fn download_image(url string, base_path string, mut w Window) { return } - // Determine extension from Content-Type - path := base_path + content_type_to_ext(ct) - - // Download file - http.download_file(url, path) or { + // Download file using a GET request with optional auth + mut get_req := http.Request{ + method: .get + url: url + } + if auth_header.len > 0 { + get_req.add_header(.authorization, auth_header) + } + resp := get_req.do() or { ch <- ImageFetchResult{ err_msg: 'Failed to download image ${url}: ${err}' is_err: true } return } + if resp.status_code != 200 { + ch <- ImageFetchResult{ + err_msg: 'Image download returned HTTP ${resp.status_code}: ${url}' + is_err: true + } + return + } + + // Determine extension from Content-Type (prefer GET response) + ct := resp.header.get(.content_type) or { ct_head } + if ct.len > 0 && !ct.starts_with('image/') { + ch <- ImageFetchResult{ + err_msg: 'Invalid content type for image (expected image/*): ${url}' + is_err: true + } + return + } + + path := base_path + content_type_to_ext(if ct.len > 0 { ct } else { 'image/png' }) + os.write_file(path, resp.body) or { + ch <- ImageFetchResult{ + err_msg: 'Failed to write cached image ${path}: ${err}' + is_err: true + } + return + } ch <- ImageFetchResult{ is_err: false diff --git a/view_rtf.v b/view_rtf.v index 77dd315..6df53f3 100644 --- a/view_rtf.v +++ b/view_rtf.v @@ -280,7 +280,13 @@ fn rtf_on_click(layout &Layout, mut e Event, mut w Window) { } else if found_run.link.starts_with('#') { w.scroll_to_view(found_run.link[1..]) } else { - os.open_uri(found_run.link) or {} + // Give app-level handler first refusal + if handler := w.view_state.link_handler { + handler(found_run.link, mut e, mut w) + } + if !e.is_handled { + os.open_uri(found_run.link) or {} + } } e.is_handled = true } diff --git a/view_state.v b/view_state.v index 809904b..91cac7c 100644 --- a/view_state.v +++ b/view_state.v @@ -11,16 +11,22 @@ import sokol.sapp // dedicated fields for type safety. struct ViewState { mut: - cursor_on_sticky bool // keeps the cursor visible during cursor movement - id_focus u32 // current view that has focus - input_cursor_on bool = true // used by cursor blink animation - menu_key_nav bool // true, menu navigated by keyboard - mouse_cursor sapp.MouseCursor // arrow, finger, ibeam, etc. - mouse_lock MouseLockCfg // mouse down/move/up/scroll/sliders, etc. use this - rtf_tooltip_rect gg.Rect // RTF abbreviation tooltip anchor rect - rtf_tooltip_text string // RTF abbreviation tooltip text - tooltip TooltipState // State for the active tooltip - registry StateRegistry // generic per-widget state maps + cursor_on_sticky bool // keeps the cursor visible during cursor movement + id_focus u32 // current view that has focus + input_cursor_on bool = true // used by cursor blink animation + menu_key_nav bool // true, menu navigated by keyboard + mouse_cursor sapp.MouseCursor // arrow, finger, ibeam, etc. + mouse_lock MouseLockCfg // mouse down/move/up/scroll/sliders, etc. use this + rtf_tooltip_rect gg.Rect // RTF abbreviation tooltip anchor rect + rtf_tooltip_text string // RTF abbreviation tooltip text + tooltip TooltipState // State for the active tooltip + registry StateRegistry // generic per-widget state maps + // link_handler, when set, is called before opening a link in the OS browser. + // If the handler sets e.is_handled = true the default os.open_uri is skipped. + link_handler ?fn (url string, mut e Event, mut w Window) + // image_auth_header_fn, when set, is called for every remote image URL to + // obtain an "Authorization" header value. Return an empty string to skip. + image_auth_header_fn ?fn (url string) string image_map BoundedImageMap = BoundedImageMap{ max_size: 100 } diff --git a/window_api.v b/window_api.v index 6030ca9..f8cf6af 100644 --- a/window_api.v +++ b/window_api.v @@ -435,6 +435,22 @@ pub fn (mut window Window) dismiss_link_context_menu() { window.view_state.link_context_menu_visible = false } +// set_link_handler registers a global link-click interceptor. +// The handler is called before the default os.open_uri behavior. +// Set e.is_handled = true inside the handler to suppress the default action. +// Pass `none` to remove any previously registered handler. +pub fn (mut window Window) set_link_handler(handler ?fn (url string, mut e Event, mut w Window)) { + window.view_state.link_handler = handler +} + +// set_image_auth_header_fn registers a callback that returns the value of +// an "Authorization" HTTP header to be sent when downloading remote images. +// Return an empty string from the callback to skip auth for a given URL. +// Pass `none` to remove any previously registered callback. +pub fn (mut window Window) set_image_auth_header_fn(f ?fn (url string) string) { + window.view_state.image_auth_header_fn = f +} + // set_rtf_tooltip shows a tooltip with the given text at the specified rect. // Used for abbreviation tooltips in RTF views. pub fn (mut window Window) set_rtf_tooltip(text string, rect gg.Rect) {