Lets SilverBullet Share publish pages to (and sync them with) a page in another SilverBullet space (local or remote).
Run Share: Page and pick SilverBullet space. The first time, choose Another space… and enter the URL of the other space (e.g. https://example.com/notes). SilverBullet checks that it really is a SilverBullet space, then signs you in with a device code: open the link that is shown, log in to the other space and approve the request. Finally enter the page path to share to; it defaults to the name of the current page.
Spaces you have shared to are remembered in this space's configuration (share.silverbullet.spaces in CONFIG), most recent first, so they are offered again next time, on every device. The sign-in tokens are not: they stay in this browser (see {[Credentials: List]} and {[Credentials: Clear]}). On another device you are asked to sign in again the first time you share to that space.
-- priority: 5
sbShare = {}
-- ETag last seen per share URI, sent as If-Match on the next conditional write
local etags = {}
local clientId = "silverbullet-share"
config.define("share.silverbullet.spaces", {
type = "array",
items = { type = "string" },
description = "SilverBullet spaces shared to before, most recently used first",
})
-- "https://h/notes/" -> "https://h/notes"
local function normalize(url)
return (url:trim():gsub("/+$", ""))
end
-- encodeURIComponent, except that "/" is kept (SilverBullet's encodePageURI)
local function encodePageURI(s)
return (encoding.urlEncode(s):gsub("%%2F", "/"))
end
function sbShare.pageUri(spaceUrl, pageName)
return normalize(spaceUrl) .. "/" .. encodePageURI(pageName)
end
-- Spaces from space config plus those with a credential on this client
local function knownSpaces()
local spaces = {}
local seen = {}
local function add(url)
url = normalize(url)
if not seen[url] then
seen[url] = true
table.insert(spaces, url)
end
end
for _, url in ipairs(config.get("share.silverbullet.spaces") or {}) do
add(url)
end
for _, key in ipairs(auth.listKeys("sb:")) do
local _, target = auth.splitKey(key)
add(target)
end
return spaces
end
-- Returns the space URL and page name of a share URI, or nil. The space's
-- own URL (a collection root) has page name "".
function sbShare.resolve(uri)
local best = nil
for _, space in ipairs(knownSpaces()) do
local prefix = space .. "/"
if (normalize(uri) == space or uri:sub(1, #prefix) == prefix)
and (best == nil or #space > #best) then
best = space
end
end
if not best then
return nil
end
if normalize(uri) == best then
return best, ""
end
return best, encoding.urlDecode(uri:sub(#best + 2))
end
-- Prefixes found not to be a SilverBullet space this session
local notASpace = {}
local function credentialKey(spaceUrl)
return "sb:" .. spaceUrl
end
-- Probes <prefix>/.ping for each path prefix of url, longest first. Only a
-- SilverBullet server answers it with an X-Server-Version header.
function sbShare.findSpaceRoot(url)
url = normalize((url:gsub("[?#].*$", "")))
local origin, path = url:match("^(https?://[^/]+)(.*)$")
if not origin then
return nil
end
local segments = path:split("/")
local parts = {}
for _, segment in ipairs(segments) do
if segment != "" then
table.insert(parts, segment)
end
end
for n = #parts, 0, -1 do
local prefix = origin
for i = 1, n do
prefix = prefix .. "/" .. parts[i]
end
if not notASpace[prefix] then
-- A network failure counts as "not a space" (but is not remembered)
local ok, resp = pcall(auth.fetch, credentialKey(prefix),
prefix .. "/.ping", {method = "GET"})
if ok and resp then
if resp.status == 200 and resp.headers
and resp.headers["x-server-version"] then
return prefix
end
-- Only definitive answers are remembered, not e.g. 401, 429 or 5xx
if resp.status == 200 or resp.status == 404 or resp.status == 405 then
notASpace[prefix] = true
end
end
end
end
return nil
end
-- Descriptor for share.transform: links stay wiki links between shared pages
service.define {
selector = "share:target:http*",
match = function(data)
if sbShare.resolve(data.uri) then
return {priority = 10}
end
return nil
end,
run = function(data)
return {
kind = "silverbullet",
defaults = {links = true, comments = true, bake = false},
-- A share URI is the page's URL in that space
webUrl = function(uri)
return uri
end,
-- Like single-page share URIs: the page's URL, without ".md"
memberUri = function(root, path)
return root .. "/" .. encodePageURI(path)
end,
link = function(l)
local out = ""
if l.toUri != nil and l.toUri != l.fromUri then
local _, page = sbShare.resolve(l.toUri)
if page == nil then
return l.text
end
out = page
end
if l.anchor != nil then
out = out .. "#" .. l.anchor
end
if out == "" then
return l.text
end
if l.alias != nil then
out = out .. "|" .. l.alias
end
return "[" .. out .. "](<" .. out .. ">)"
end,
}
end,
}
local function fsUrl(spaceUrl, uri)
local page = encoding.urlDecode(uri:sub(#spaceUrl + 2))
return spaceUrl .. "/.fs/" .. encodePageURI(page) .. ".md"
end
-- Reads a page, nil when it doesn't exist. Never signs in.
local function readPage(spaceUrl, uri)
local resp = auth.fetch(credentialKey(spaceUrl), fsUrl(spaceUrl, uri), {
method = "GET",
responseEncoding = "text/markdown",
})
if resp.status == 404 then
return nil
elseif resp.status == 401 then
error("Not connected to " .. spaceUrl .. ": share a page to it in push or sync mode to sign in")
elseif resp.status != 200 then
error("SilverBullet share: read failed (" .. resp.status .. ")")
end
etags[uri] = resp.headers and resp.headers.etag
return resp.body
end
-- A page (or page prefix) of a known space, not the space itself
local function transportMatch(data)
local _, page = sbShare.resolve(data.uri)
if page and page != "" then
return {priority = 20}
end
return nil
end
service.define {
selector = "net.readURI:http*",
match = transportMatch,
run = function(data)
return readPage(sbShare.resolve(data.uri), data.uri)
end,
}
service.define {
selector = "net.writeURI:http*",
match = transportMatch,
run = function(data)
local spaceUrl = sbShare.resolve(data.uri)
local headers = {}
if data.conditional and etags[data.uri] then
headers["If-Match"] = etags[data.uri]
end
local resp = auth.fetch(credentialKey(spaceUrl), fsUrl(spaceUrl, data.uri), {
method = "PUT",
headers = headers,
body = data.content,
}, true)
if resp.status == 412 then
return {conflict = true}
end
if resp.status < 200 or resp.status >= 300 then
error("SilverBullet share: write failed (" .. resp.status .. ")")
end
etags[data.uri] = resp.headers and resp.headers.etag
end,
}
-- The pages under root, as root .. "/" .. encodePageURI(rest): the form the
-- descriptor's memberUri builds, however root itself is encoded. The space
-- itself isn't listed: its other pages aren't a collection's to remove.
service.define {
selector = "net.listURI:http*",
match = transportMatch,
run = function(data)
local spaceUrl, rootPage = sbShare.resolve(data.uri)
local root = normalize(data.uri)
local resp = auth.fetch(credentialKey(spaceUrl), spaceUrl .. "/.fs", {
method = "GET",
responseEncoding = "application/json",
})
if resp.status == 401 then
error("Not connected to " .. spaceUrl .. ": share a page to it in push or sync mode to sign in")
elseif resp.status != 200 then
error("SilverBullet share: listing failed (" .. resp.status .. ")")
end
local prefix = rootPage .. "/"
local entries = {}
for _, file in ipairs(resp.body or {}) do
local name = file.name
if name:endsWith(".md") and name:startsWith(prefix) then
local rest = name:sub(#prefix + 1, -4)
table.insert(entries, {uri = root .. "/" .. encodePageURI(rest)})
end
end
return entries
end,
}
service.define {
selector = "net.deleteURI:http*",
match = transportMatch,
run = function(data)
local spaceUrl = sbShare.resolve(data.uri)
local resp = auth.fetch(credentialKey(spaceUrl), fsUrl(spaceUrl, data.uri), {
method = "DELETE",
}, true)
if resp.status != 404 and (resp.status < 200 or resp.status >= 300) then
error("SilverBullet share: delete failed (" .. resp.status .. ")")
end
etags[data.uri] = nil
end,
}
-- Makes sure this client has a credential (may run the device flow)
local function signIn(spaceUrl)
auth.headers(credentialKey(spaceUrl))
end
-- Moves (or adds) root to the front of share.silverbullet.spaces
local function rememberSpace(root)
root = normalize(root)
local current = config.get("share.silverbullet.spaces") or {}
local list = {root}
for _, url in ipairs(current) do
url = normalize(url)
if url != root then
table.insert(list, url)
end
end
if table.concat(list, "\n") != table.concat(current, "\n") then
system.invokeFunction("configuration-manager.setConfigValue",
"share.silverbullet.spaces", list)
end
end
local function tokenCredential(token, target, previous)
return {
access_token = token.access_token,
refresh_token = token.refresh_token or (previous and previous.refresh_token),
expires_at = token.expires_in and (os.time() * 1000 + token.expires_in * 1000),
username = token.username or (previous and previous.username),
space = target,
}
end
auth.defineProvider {
id = "sb",
connect = function(target)
local ok, result = pcall(oauth.deviceFlow, {
deviceCodeUrl = target .. "/.auth/device/code",
tokenUrl = target .. "/.auth/token",
clientId = clientId,
})
if not ok then
if tostring(result):find("unauthorized_client", 1, true) then
error("Remote server too old for sharing (needs a newer SilverBullet)")
end
error(result)
end
return tokenCredential(result, target)
end,
refresh = function(cred)
local resp = net.proxyFetch(cred.space .. "/.auth/token", {
method = "POST",
headers = {["Content-Type"] = "application/x-www-form-urlencoded"},
body = "grant_type=refresh_token&refresh_token="
.. encoding.urlEncode(cred.refresh_token)
.. "&client_id=" .. clientId,
})
if resp.status != 200 then
error("SilverBullet share: token refresh failed (" .. resp.status .. ")")
end
return tokenCredential(resp.body, cred.space, cred)
end,
headers = function(cred)
return {Authorization = "Bearer " .. cred.access_token}
end,
}
local anotherSpace = "Another space…"
service.define {
selector = "share:onboard",
match = {
name = "SilverBullet space",
description = "Share this page to a page in another SilverBullet space",
},
run = function(data)
local options = {}
for _, url in ipairs(config.get("share.silverbullet.spaces") or {}) do
table.insert(options, {name = normalize(url)})
end
table.insert(options, {name = anotherSpace})
local picked = editor.filterBox("SilverBullet space", options)
if not picked then
return
end
local root
if picked.name == anotherSpace then
local url = editor.prompt("Space URL")
if not url or url:trim() == "" then
return
end
root = sbShare.findSpaceRoot(url)
if not root then
error("Not a SilverBullet space: " .. normalize(url))
end
else
root = picked.name
end
signIn(root)
local path = editor.prompt("Page path in that space", data.name)
if not path then
return
end
-- "/Shared/Page.md" -> "Shared/Page"
path = path:trim():gsub("^/+", ""):gsub("%.md$", "")
if path == "" then
return
end
local uri = sbShare.pageUri(root, path)
if readPage(root, uri) and not editor.confirm(path .. " already exists in "
.. root .. ". Overwrite it?") then
return
end
rememberSpace(root)
-- The push that follows uploads the page
return {uri = uri, mode = "push"}
end,
}
service.define {
selector = "share:ensure",
match = function(data)
local uri = data.uri or ""
if not uri:match("^https?://[^/]+") then
return nil
end
if not sbShare.resolve(uri) then
-- Another provider writes this URI (e.g. GitHub)
for _, m in ipairs(service.discover("net.writeURI:" .. uri, {uri = uri}) or {}) do
if (m.priority or 0) > 0 then
return nil
end
end
end
return {priority = 10}
end,
run = function(data)
local root = sbShare.resolve(data.uri)
local known = root != nil
local question
if known then
-- An expired credential that refreshes needs no sign-in
if auth.optionalHeaders(credentialKey(root)) then
return true
end
question = "Sign in to " .. root .. "?"
else
root = sbShare.findSpaceRoot(data.uri)
if not root then
return false
end
-- Without a connection, reads would go to the generic https: reader,
-- which returns the remote's HTML (or sign-in page) instead of the page
question = "Connect to " .. root .. "?"
end
if not editor.confirm(question) then
error("Not connected to " .. root)
end
local ok, err = pcall(signIn, root)
if not ok then
error("Not connected to " .. root .. ": " .. tostring(err))
end
if not known then
rememberSpace(root)
end
return true
end,
}