Files

341 lines
13 KiB
Lua

local DocumentRegistry = require("document/documentregistry")
local InfoMessage = require("ui/widget/infomessage")
local MultiInputDialog = require("ui/widget/multiinputdialog")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local datetime = require("datetime")
local ffiUtil = require("ffi/util")
local http = require("socket.http")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local ltn12 = require("ltn12")
local socket = require("socket")
local socketutil = require("socketutil")
local util = require("util")
local _ = require("gettext")
local WebDav = {
name = _("WebDAV"),
type = "webdav",
base = nil, -- CloudStorage self, will be filled in Cloud:onShowCloudStorageList()
}
local WebDavApi = {}
-- Trim leading & trailing slashes from string `s` (based on util.trim)
function WebDavApi.trim_slashes(s)
local from = s:match"^/*()"
return from > #s and "" or s:match(".*[^/]", from)
end
-- Trim trailing slashes from string `s` (based on util.rtrim)
function WebDavApi.rtrim_slashes(s)
local n = #s
while n > 0 and s:find("^/", n) do
n = n - 1
end
return s:sub(1, n)
end
-- Append path to address with a slash separator, trimming any unwanted slashes in the process.
function WebDavApi.getJoinedPath(address, path)
local path_encoded = util.urlEncode(path, "/") or ""
-- Strip leading & trailing slashes from `path`
local sane_path = WebDavApi.trim_slashes(path_encoded)
-- Strip trailing slashes from `address` for now
local sane_address = WebDavApi.rtrim_slashes(address)
-- Join our final URL
return sane_address .. "/" .. sane_path
end
function WebDavApi.listFolder(address, user, pass, folder_path, include_folders)
local path = folder_path or ""
path = WebDavApi.trim_slashes(path)
address = WebDavApi.rtrim_slashes(address)
-- Join our final URL, which *must* have a trailing / (it's a URL)
-- This is where we deviate from getJoinedPath ;).
local webdav_url = address .. "/" .. util.urlEncode(path, "/")
if webdav_url:sub(-1) ~= "/" then
webdav_url = webdav_url .. "/"
end
-- Used to detect the "current folder" item.
-- The server can store the full url or the relative path in it.
local webdav_url_path = WebDavApi.trim_slashes(util.urlDecode(webdav_url:match("^https?://[^/]*(.*)$") or webdav_url))
local sink = {}
local data = [[<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:prop><a:resourcetype/><a:getcontentlength/><a:getlastmodified/></a:prop></a:propfind>]]
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local request = {
url = webdav_url,
method = "PROPFIND",
headers = {
["Content-Type"] = "application/xml",
["Depth"] = "1",
["Content-Length"] = #data,
},
user = user,
password = pass,
source = ltn12.source.string(data),
sink = ltn12.sink.table(sink),
}
local code, headers, status = socket.skip(1, http.request(request))
socketutil:reset_timeout()
if headers == nil then
logger.dbg("WebDavApi:listFolder: No response:", status or code)
return
elseif not code or code < 200 or code > 299 then
-- got a response, but it wasn't a success (e.g. auth failure)
logger.dbg("WebDavApi:listFolder: Request failed:", status or code)
logger.dbg("WebDavApi:listFolder: Response headers:", headers)
logger.dbg("WebDavApi:listFolder: Response body:", table.concat(sink))
return
end
local res = table.concat(sink)
if res == "" then return end
local show_unsupported = G_reader_settings:isTrue("show_unsupported")
local item_list = {}
-- iterate through the <d:response> tags, each containing an entry
for item in res:gmatch("<[^>]*response[^>]*>(.-)</[^>]*response>") do
--logger.dbg("WebDav catalog item=", item)
-- <d:href> is the path and filename of the entry.
local item_fullpath = util.urlDecode(item:match("<[^>]*href[^>]*>(.*)</[^>]*href>"))
local item_name = ffiUtil.basename(util.htmlEntitiesToUtf8(item_fullpath))
local is_not_collection = item:find("<[^>]*resourcetype%s*/>") or
item:find("<[^>]*resourcetype>%s*</[^>]*resourcetype>")
if is_not_collection then
if show_unsupported or DocumentRegistry:hasProvider(item_name) then
local file_size = tonumber(item:match("<[^>]*getcontentlength[^>]*>(%d+)</[^>]*getcontentlength>"))
local modification, suffix, mandatory
if include_folders then
local item_modified = item:match("<[^>]*getlastmodified[^>]*>(.*)</[^>]*getlastmodified>")
modification = item_modified and datetime.stringRFC1123ToSeconds(item_modified)
suffix = util.getFileNameSuffix(item_name)
mandatory = util.getFriendlySize(file_size)
end
table.insert(item_list, {
is_file = true,
text = item_name,
url = path .. "/" .. item_name,
filesize = file_size,
modification = modification,
suffix = suffix,
mandatory = mandatory,
})
end
elseif item:find("<[^>]*collection[^<]*/>") or item:find("<[^>]*collection>%s*</[^>]*collection>") then
if include_folders then
local is_not_current_dir = WebDavApi.trim_slashes(item_fullpath) ~= webdav_url_path
if is_not_current_dir then
table.insert(item_list, {
is_folder = true,
text = item_name .. "/",
url = path .. "/" .. item_name,
})
end
end
end
end
return item_list
end
function WebDavApi.downloadFile(file_url, user, pass, local_path, progress_callback)
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
logger.dbg("WebDavApi: downloading file: ", file_url)
local handle = ltn12.sink.file(io.open(local_path, "w"))
if progress_callback then
handle = socketutil.chainSinkWithProgressCallback(handle, progress_callback)
end
local code, headers, status = socket.skip(1, http.request {
url = file_url,
method = "GET",
sink = handle,
user = user,
password = pass,
})
socketutil:reset_timeout()
if code ~= 200 then
logger.warn("WebDavApi: cannot download file:", status or code)
logger.dbg("WebDavApi: Response headers:", headers)
end
return code, headers and headers.etag
end
function WebDavApi.uploadFile(file_url, user, pass, local_path, etag)
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
-- If-Match uses strong comparison (RFC 7232 §3.1), so a weak validator
-- (W/"…", returned e.g. for gzip-compressed responses) can never match and
-- would 412 forever. Strip the weak prefix; proxies keep the same value.
if type(etag) == "string" then
etag = etag:gsub("^%s*[Ww]/", "")
end
local code, _, status = socket.skip(1, http.request{
url = file_url,
method = "PUT",
source = ltn12.source.file(io.open(local_path, "r")),
user = user,
password = pass,
headers = {
["Content-Length"] = lfs.attributes(local_path, "size"),
["If-Match"] = etag,
},
})
socketutil:reset_timeout()
if type(code) == "number" and code >= 200 and code <= 299 then
code = 200
else
logger.warn("WebDavApi: cannot upload file:", status or code)
end
return code
end
function WebDavApi.deleteFile(file_url, user, pass)
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local code, _, status = socket.skip(1, http.request{
url = file_url,
method = "DELETE",
user = user,
password = pass,
})
socketutil:reset_timeout()
if type(code) == "number" and code >= 200 and code <= 299 then
return true
end
logger.warn("WebDavApi: cannot delete file:", status or code)
end
function WebDavApi.createFolder(folder_url, user, pass)
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local code, _, status = socket.skip(1, http.request{
url = folder_url,
method = "MKCOL",
user = user,
password = pass,
})
socketutil:reset_timeout()
if type(code) == "number" and code >= 200 and code <= 299 then
return true
end
logger.warn("WebDavApi: cannot create folder:", status or code)
end
-- WebDav
function WebDav.run(caller_callback)
if NetworkMgr:willRerunWhenConnected(function() WebDav.run(caller_callback) end) then
return
end
return caller_callback()
end
function WebDav.listFolder(url, include_folders)
local base = WebDav.base
-- list or nil
return WebDavApi.listFolder(base.address, base.username, base.password, url, include_folders)
end
function WebDav.downloadFile(url, local_path, progress_callback)
local base = WebDav.base
local path = WebDavApi.getJoinedPath(base.address, url)
-- code, etag
return WebDavApi.downloadFile(path, base.username, base.password, local_path, progress_callback)
end
function WebDav.uploadFile(url, local_path, etag)
local base = WebDav.base
local path = WebDavApi.getJoinedPath(base.address, url)
path = WebDavApi.getJoinedPath(path, ffiUtil.basename(local_path))
-- code
return WebDavApi.uploadFile(path, base.username, base.password, local_path, etag)
end
function WebDav.deleteFile(url)
local base = WebDav.base
local path = WebDavApi.getJoinedPath(base.address, url)
-- ok
return WebDavApi.deleteFile(path, base.username, base.password)
end
function WebDav.createFolder(url, folder_name)
local base = WebDav.base
local path = WebDavApi.getJoinedPath(base.address, url)
path = WebDavApi.getJoinedPath(path, folder_name)
-- ok
return WebDavApi.createFolder(path, base.username, base.password)
end
function WebDav.config(server_idx, caller_callback)
local text_info = _([[Server address must be of the form http(s)://domain.name/path
This can point to a sub-directory of the WebDAV server.
The start folder is appended to the server path.]])
local item = server_idx and WebDav.base.servers[server_idx] or { type = WebDav.type }
local settings_dialog
settings_dialog = MultiInputDialog:new{
title = _("WebDAV server settings"),
fields = {
{
text = item.name,
hint = _("Name"),
},
{
text = item.address,
hint = _("WebDAV address, for example https://example.com/dav"),
},
{
text = item.username,
hint = _("Username"),
},
{
text = item.password,
text_type = "password",
hint = _("Password"),
},
{
text = item.url,
hint = _("Start folder, for example /books"),
},
},
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(settings_dialog)
end,
},
{
text = _("Info"),
callback = function()
UIManager:show(InfoMessage:new{ text = text_info })
end,
},
{
text = _("Save"),
callback = function()
local fields = settings_dialog:getFields()
-- make sure the URL is a valid path
if fields[5] ~= "" then
if not fields[5]:match('^/') then
fields[5] = '/' .. fields[5]
end
fields[5] = fields[5]:gsub("/$", "")
end
item.name = fields[1]
item.address = fields[2]
item.username = fields[3]
item.password = fields[4]
item.url = fields[5]
UIManager:close(settings_dialog)
caller_callback(item)
end,
},
},
},
}
UIManager:show(settings_dialog)
settings_dialog:onShowKeyboard()
end
return WebDav