Skip to content

Commit 1b610cd

Browse files
author
troiganto
committed
feat(attach): add OrgAttach:attach_url()
1 parent 7ca573c commit 1b610cd

File tree

10 files changed

+434
-4
lines changed

10 files changed

+434
-4
lines changed

docs/configuration.org

+30
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,36 @@ See [[https://orgmode.org/manual/Property-Inheritance.html][Property Inheritance
556556
- Default: ~{ [':tangle'] = 'no', [':noweb'] = no }~
557557
Default header args for extracting source code. See [[#extract-source-code-tangle][Extract source code (tangle)]] for more details.
558558

559+
*** org_resource_download_policy
560+
:PROPERTIES:
561+
:CUSTOM_ID: org_resource_download_policy
562+
:END:
563+
- Type: ='always' | 'prompt' | 'safe' | 'never'=
564+
- Default: ='prompt'=
565+
Policy applied to requests to obtain remote resources.
566+
567+
- =always= - Always download remote resources (dangerous!)
568+
- =prompt= - Prompt before downloading an unsafe resource
569+
- =safe= - Only download resources allowed by [[#org_safe_remote_resources][org_safe_remote_resources]]
570+
- =never= - Never download any resources
571+
572+
In Emacs Orgmode, this affects keywords like =#+setupfile= and =#+include=
573+
on export, =org-persist-write:url=; and =org-attach-url= in non-interactive
574+
sessions. Nvim Orgmode currently does not use this option, but defines it
575+
for future use.
576+
577+
*** org_safe_remote_resources
578+
:PROPERTIES:
579+
:CUSTOM_ID: org_safe_remote_resources
580+
:END:
581+
- Type: =string[]=
582+
- Default: ={}=
583+
584+
List of regex patterns matching safe URIs. URI regexps are applied to both
585+
URLs and Org files requesting remote resources. The test uses
586+
=vim.regex()=, so the regexes are always interpreted as magic and
587+
case-sensitive.
588+
559589
*** calendar_week_start_day
560590
:PROPERTIES:
561591
:CUSTOM_ID: calendar_week_start_day

lua/orgmode/attach/core.lua

+26-1
Original file line numberDiff line numberDiff line change
@@ -438,11 +438,36 @@ function AttachCore:attach(node, file, opts)
438438
end)
439439
end
440440

441-
---@class orgmode.attach.core.attach_buffer.opts
441+
---@class orgmode.attach.core.attach_url.opts
442442
---@inlinedoc
443443
---@field set_dir_method fun(): OrgPromise<orgmode.attach.core.new_method>
444444
---@field new_dir fun(): OrgPromise<string | nil>
445445

446+
---Download a file from a URL and attach it to the current outline node.
447+
---
448+
---@param node OrgAttachNode
449+
---@param url string URL to the file to attach
450+
---@param opts orgmode.attach.core.attach_url.opts
451+
---@return OrgPromise<string|nil> attachment_name
452+
function AttachCore:attach_url(node, url, opts)
453+
local basename = basename_safe(url)
454+
return self:get_dir_or_create(node, opts.set_dir_method, opts.new_dir):next(function(attach_dir)
455+
local attach_file = vim.fs.joinpath(attach_dir, basename)
456+
return fileops.download_file(url, attach_file, { exist_ok = false }):next(function(success)
457+
if not success then
458+
return nil
459+
end
460+
EventManager.dispatch(EventManager.event.AttachChanged:new(node, attach_dir))
461+
node:add_auto_tag()
462+
local link = self.links:store_link_to_attachment({ attach_dir = attach_dir, original = url })
463+
vim.fn.setreg(vim.v.register, link)
464+
return basename
465+
end)
466+
end)
467+
end
468+
469+
---@alias orgmode.attach.core.attach_buffer.opts orgmode.attach.core.attach_url.opts
470+
446471
---Attach buffer's contents to current outline node.
447472
---
448473
---Throws a file-exists error if it would overwrite an existing filename.

lua/orgmode/attach/fileops.lua

+40
Original file line numberDiff line numberDiff line change
@@ -361,4 +361,44 @@ function M.remove_directory(path, opts)
361361
end)
362362
end
363363

364+
--[[
365+
-- Scary hacks 💀
366+
--]]
367+
368+
---Helper function to `download_file`.
369+
---This uses NetRW to download a file and returns the download location.
370+
---@param url string
371+
---@return OrgPromise<string> tmpfile
372+
local function netrw_read(url)
373+
return Promise.new(function(resolve, reject)
374+
if not vim.g.loaded_netrwPlugin then
375+
return reject('Netrw plugin must be loaded in order to download urls.')
376+
end
377+
vim.schedule(function()
378+
local ok, err = pcall(vim.fn['netrw#NetRead'], 3, url)
379+
if ok then
380+
resolve(vim.b.netrw_tmpfile)
381+
else
382+
reject(err)
383+
end
384+
end)
385+
end)
386+
end
387+
388+
---Download a file via NetRW.
389+
---The file is first downloaded to a temporary location (no matter the value of
390+
---`exist_ok`) and only then copied over to `dest`. The copy operation uses the
391+
---`exist_ok` flag exactly like `copy_file`.
392+
---@param url string
393+
---@param dest string
394+
---@param opts? {exist_ok: boolean?}
395+
---@return OrgPromise<true> success
396+
function M.download_file(url, dest, opts)
397+
opts = opts or {}
398+
local exist_ok = opts.exist_ok or false
399+
return netrw_read(url):next(function(source)
400+
return M.copy_file(source, dest, { excl = not exist_ok, ficlone = true, ficlone_force = false })
401+
end)
402+
end
403+
364404
return M

lua/orgmode/attach/init.lua

+54-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ local Input = require('orgmode.ui.input')
44
local Menu = require('orgmode.ui.menu')
55
local Promise = require('orgmode.utils.promise')
66
local config = require('orgmode.config')
7+
local remote_resource = require('orgmode.objects.remote_resource')
78
local ui = require('orgmode.attach.ui')
89
local utils = require('orgmode.utils')
910

@@ -65,6 +66,13 @@ function Attach:_build_menu()
6566
return self:attach_lns()
6667
end,
6768
})
69+
menu:add_option({
70+
label = 'Attach a file by download from URL.',
71+
key = 'u',
72+
action = function()
73+
return self:attach_url()
74+
end,
75+
})
6876
menu:add_option({
6977
label = "Attach a buffer's contents.",
7078
key = 'b',
@@ -376,18 +384,62 @@ function Attach:attach(file, opts)
376384
:wait(MAX_TIMEOUT)
377385
end
378386

379-
---@class orgmode.attach.attach_buffer.Options
387+
---@class orgmode.attach.attach_url.Options
380388
---@inlinedoc
381389
---@field visit_dir? boolean if true, visit the directory subsequently using
382390
--- `org_attach_visit_command`
383391
---@field node? OrgAttachNode
384392

393+
---Download a URL.
394+
---
395+
---@param url? string
396+
---@param opts? orgmode.attach.attach_url.Options
397+
---@return string|nil attachment_name
398+
function Attach:attach_url(url, opts)
399+
local node = opts and opts.node or self.core:get_current_node()
400+
local visit_dir = opts and opts.visit_dir or false
401+
return Promise
402+
.resolve()
403+
:next(function()
404+
if not url then
405+
return Input.open('URL of the file to attach: ')
406+
end
407+
return remote_resource.should_fetch(url):next(function(ok)
408+
if not ok then
409+
error(("remote resource %s is unsafe, won't download"):format(url))
410+
end
411+
return url
412+
end)
413+
end)
414+
---@param chosen_url? string
415+
:next(function(chosen_url)
416+
if not chosen_url then
417+
return nil
418+
end
419+
return self.core:attach_url(node, chosen_url, {
420+
set_dir_method = get_set_dir_method(),
421+
new_dir = ui.ask_attach_dir_property,
422+
})
423+
end)
424+
:next(function(attachment_name)
425+
if attachment_name then
426+
utils.echo_info(('File %s is now an attachment'):format(attachment_name))
427+
if visit_dir then
428+
local attach_dir = self.core:get_dir(node)
429+
self.core:reveal_nvim(attach_dir)
430+
end
431+
end
432+
return attachment_name
433+
end)
434+
:wait(MAX_TIMEOUT)
435+
end
436+
385437
---Attach buffer's contents to current outline node.
386438
---
387439
---Throws a file-exists error if it would overwrite an existing filename.
388440
---
389441
---@param buffer? string | integer A buffer number or name.
390-
---@param opts? orgmode.attach.attach_buffer.Options
442+
---@param opts? orgmode.attach.attach_url.Options
391443
---@return string|nil attachment_name
392444
function Attach:attach_buffer(buffer, opts)
393445
local node = opts and opts.node or self.core:get_current_node()

lua/orgmode/config/_meta.lua

+2
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@
249249
---@field org_attach_archive_delete 'always' | 'ask' | 'never' Determines whether to delete a headline's attachments when it is archived. Default: 'never'
250250
---@field org_attach_id_to_path_function_list (string | fun(id: string): (string|nil))[] List of functions used to derive the attachments directory from an ID property.
251251
---@field org_attach_sync_delete_empty_dir 'always' | 'ask' | 'never' Determines whether to delete empty directories when using `org.attach.sync()`. Default: 'ask'
252+
---@field org_resource_download_policy 'always' | 'prompt' | 'safe' | 'never' Policy for downloading files from the Internet. Default: 'prompt'
253+
---@field org_safe_remote_resources string[] List of regex patterns for URIs considered always safe to download from. Default: {}
252254
---@field win_split_mode? 'horizontal' | 'vertical' | 'auto' | 'float' | string[] How to open agenda and capture windows. Default: 'horizontal'
253255
---@field win_border? 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | string[] Border configuration for `win_split_mode = 'float'`. Default: 'single'
254256
---@field notifications? OrgNotificationsConfig Notification settings

lua/orgmode/config/defaults.lua

+2
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ local DefaultConfig = {
8686
'fallback_folder_format',
8787
},
8888
org_attach_sync_delete_empty_dir = 'ask',
89+
org_resource_download_policy = 'prompt',
90+
org_safe_remote_resources = {},
8991
win_split_mode = 'horizontal',
9092
win_border = 'single',
9193
notifications = {
+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
local config = require('orgmode.config')
2+
local fs = require('orgmode.utils.fs')
3+
local utils = require('orgmode.utils')
4+
local Menu = require('orgmode.ui.menu')
5+
local State = require('orgmode.state.state')
6+
local Promise = require('orgmode.utils.promise')
7+
8+
local M = {}
9+
10+
---Return true if the URI should be fetched.
11+
---@param uri string
12+
---@return OrgPromise<boolean> safe
13+
function M.should_fetch(uri)
14+
local policy = config.org_resource_download_policy
15+
return Promise.resolve(policy == 'always' or M.is_uri_safe(uri)):next(function(safe)
16+
if safe then
17+
return true
18+
end
19+
if policy == 'prompt' then
20+
return M.confirm_safe(uri)
21+
end
22+
return false
23+
end)
24+
end
25+
26+
---@param resource_uri string
27+
---@param file_uri string | false
28+
---@param patterns string[]
29+
---@return boolean matches
30+
local function check_patterns(resource_uri, file_uri, patterns)
31+
for _, pattern in ipairs(patterns) do
32+
local re = vim.regex(pattern)
33+
if re:match_str(resource_uri) or (file_uri and re:match_str(file_uri)) then
34+
return true
35+
end
36+
end
37+
return false
38+
end
39+
40+
---Check the uri matches any of the (configured or cached) safe patterns.
41+
---@param uri string
42+
---@return OrgPromise<boolean> safe
43+
function M.is_uri_safe(uri)
44+
local current_file = fs.get_real_path(utils.current_file_path())
45+
---@type string | false # deduced type is `string | boolean`
46+
local file_uri = current_file and vim.uri_from_fname(current_file) or false
47+
local uri_patterns = {}
48+
if config.org_safe_remote_resources then
49+
vim.list_extend(uri_patterns, config.org_safe_remote_resources)
50+
end
51+
return State:load():next(function(state)
52+
local cached = state['org_safe_remote_resources']
53+
if cached then
54+
vim.list_extend(uri_patterns, cached)
55+
end
56+
return check_patterns(uri, file_uri, uri_patterns)
57+
end)
58+
end
59+
60+
---@param uri string
61+
---@return string escaped
62+
local function uri_to_pattern(uri)
63+
-- Escape backslashes, disable magic characters, anchor front and back of the
64+
-- pattern.
65+
return string.format([[\V\^%s\$]], uri:gsub([[\]], [[\\]]))
66+
end
67+
68+
---@param filename string
69+
---@return string escaped
70+
local function filename_to_pattern(filename)
71+
return uri_to_pattern(vim.uri_from_fname(filename))
72+
end
73+
74+
---@param domain string
75+
---@return string escaped
76+
local function domain_to_pattern(domain)
77+
-- We construct the following regex:
78+
-- 1. http or https protocol;
79+
-- 2. followed by userinfo (`name:password@`),
80+
-- 3. followed by potentially `www.` (for convenience),
81+
-- 4. followed by the domain (in very-nomagic mode)
82+
-- 5. followed by either a slash or nothing at all.
83+
return string.format(
84+
[[\v^https?://([^@/?#]*\@)?(www\.)?(\V%s\v)($|/)]],
85+
-- `domain` here includes the host name and port. If it doesn't contain
86+
-- characters illegal in a host or port, this encoding should do nothing.
87+
-- If it contains illegal characters, the domain is broken in a safe way.
88+
vim.uri_encode(domain)
89+
)
90+
end
91+
92+
---@param pattern string
93+
---@return OrgPromise<OrgState>
94+
local function cache_safe_pattern(pattern)
95+
---@param state OrgState
96+
return State:load():next(function(state)
97+
-- We manipulate `cached` in a strange way here to ensure that `state` gets
98+
-- marked as dirty.
99+
local patterns = { pattern }
100+
local cached = state['org_safe_remote_resources']
101+
if cached then
102+
vim.list_extend(patterns, cached)
103+
end
104+
state['org_safe_remote_resources'] = patterns
105+
end)
106+
end
107+
108+
---Ask the user if URI should be considered safe.
109+
---@param uri string
110+
---@return OrgPromise<boolean> safe
111+
function M.confirm_safe(uri)
112+
---@type OrgMenu
113+
return Promise.new(function(resolve)
114+
local menu = Menu:new({
115+
title = string.format('An org-mode document would like to download %s, which is not considered safe.', uri),
116+
prompt = 'Do you want to download this?',
117+
})
118+
menu:add_option({
119+
key = '!',
120+
label = 'Yes, and mark it as safe.',
121+
action = function()
122+
cache_safe_pattern(uri_to_pattern(uri))
123+
return true
124+
end,
125+
})
126+
local authority = uri:match('^https?://([^/?#]*)')
127+
-- `domain` here includes the host name and port.
128+
local domain = authority and authority:match('^[^@]*@(.*)$') or authority
129+
if domain then
130+
menu:add_option({
131+
key = 'd',
132+
label = string.format('Yes, and mark the domain as safe. (%s)', domain),
133+
action = function()
134+
cache_safe_pattern(domain_to_pattern(domain))
135+
return true
136+
end,
137+
})
138+
end
139+
local filename = fs.get_real_path(utils.current_file_path())
140+
if filename then
141+
menu:add_option({
142+
key = 'f',
143+
label = string.format('Yes, and mark the org file as safe. (%s)', filename),
144+
action = function()
145+
cache_safe_pattern(filename_to_pattern(filename))
146+
return true
147+
end,
148+
})
149+
end
150+
menu:add_option({
151+
key = 'y',
152+
label = 'Yes, just this once.',
153+
action = function()
154+
return true
155+
end,
156+
})
157+
menu:add_option({
158+
key = 'n',
159+
label = 'No, skip this resource.',
160+
action = function()
161+
return false
162+
end,
163+
})
164+
menu:add_separator({ icon = ' ', length = 1 })
165+
resolve(menu:open())
166+
end)
167+
end
168+
169+
return M

0 commit comments

Comments
 (0)