diff --git a/CliClient/app/command-apidoc.js b/CliClient/app/command-apidoc.js index f5d0969bd..da01df3cf 100644 --- a/CliClient/app/command-apidoc.js +++ b/CliClient/app/command-apidoc.js @@ -100,6 +100,17 @@ class Command extends BaseCommand { lines.push('* **DELETE**: To delete items.'); lines.push(''); + lines.push('# Filtering data'); + lines.push(''); + lines.push('You can change the fields that will be returned by the API using the `fields=` query parameter, which takes a list of comma separated fields. For example, to get the longitude and latitude of a note, use this:'); + lines.push(''); + lines.push('\tcurl http://localhost:41184/notes/ABCD123?fields=longitude,latitude'); + lines.push(''); + lines.push('To get the IDs only of all the tags:'); + lines.push(''); + lines.push('\tcurl http://localhost:41184/tags?fields=id'); + lines.push(''); + lines.push('# About the property types'); lines.push(''); lines.push('* Text is UTF-8.'); @@ -192,6 +203,13 @@ class Command extends BaseCommand { lines.push(''); } + if (model.type === BaseModel.TYPE_RESOURCE) { + lines.push('## GET /resources/:id/file'); + lines.push(''); + lines.push('Gets the actual file associated with this resource.'); + lines.push(''); + } + lines.push('## POST /' + tableName); lines.push(''); lines.push('Creates a new ' + singular); diff --git a/CliClient/tests/services_rest_Api.js b/CliClient/tests/services_rest_Api.js index e2c2ae8b9..94dbaded1 100644 --- a/CliClient/tests/services_rest_Api.js +++ b/CliClient/tests/services_rest_Api.js @@ -9,7 +9,7 @@ const Note = require('lib/models/Note'); const Tag = require('lib/models/Tag'); const Resource = require('lib/models/Resource'); -jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; +jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; process.on('unhandledRejection', (reason, p) => { console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); @@ -211,12 +211,15 @@ describe('services_rest_Api', function() { it('should handle tokens', async (done) => { api = new Api('mytoken'); - const hasThrown = await checkThrowAsync(async () => await api.route('GET', 'notes')); + let hasThrown = await checkThrowAsync(async () => await api.route('GET', 'notes')); expect(hasThrown).toBe(true); const response = await api.route('GET', 'notes', { token: 'mytoken' }) expect(response.length).toBe(0); + hasThrown = await checkThrowAsync(async () => await api.route('POST', 'notes', null, JSON.stringify({title:'testing'}))); + expect(hasThrown).toBe(true); + done(); }); diff --git a/ReactNativeClient/lib/ClipperServer.js b/ReactNativeClient/lib/ClipperServer.js index 4527d2c67..cddd878e5 100644 --- a/ReactNativeClient/lib/ClipperServer.js +++ b/ReactNativeClient/lib/ClipperServer.js @@ -5,6 +5,7 @@ const { Logger } = require('lib/logger.js'); const randomClipperPort = require('lib/randomClipperPort'); const enableServerDestroy = require('server-destroy'); const Api = require('lib/services/rest/Api'); +const ApiResponse = require('lib/services/rest/ApiResponse'); const multiparty = require('multiparty'); class ClipperServer { @@ -92,13 +93,14 @@ class ClipperServer { this.server_.on('request', async (request, response) => { - const writeCorsHeaders = (code, contentType = "application/json") => { - response.writeHead(code, { + const writeCorsHeaders = (code, contentType = "application/json", additionalHeaders = null) => { + const headers = Object.assign({}, { "Content-Type": contentType, 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, PUT, PATCH, DELETE', 'Access-Control-Allow-Headers': 'X-Requested-With,content-type', - }); + }, additionalHeaders ? additionalHeaders : {}); + response.writeHead(code, headers); } const writeResponseJson = (code, object) => { @@ -113,8 +115,23 @@ class ClipperServer { response.end(); } + const writeResponseInstance = (code, instance) => { + if (instance.type === 'attachment') { + const filename = instance.attachmentFilename ? instance.attachmentFilename : 'file'; + writeCorsHeaders(code, instance.contentType ? instance.contentType : 'application/octet-stream', { + 'Content-disposition': 'attachment; filename=' + filename, + 'Content-Length': instance.body.length, + }); + response.end(instance.body); + } else { + throw new Error('Not implemented'); + } + } + const writeResponse = (code, response) => { - if (typeof response === 'string') { + if (response instanceof ApiResponse) { + writeResponseInstance(code, response); + } else if (typeof response === 'string') { writeResponseText(code, response); } else { writeResponseJson(code, response); diff --git a/ReactNativeClient/lib/models/Resource.js b/ReactNativeClient/lib/models/Resource.js index 60c8ab086..de8391323 100644 --- a/ReactNativeClient/lib/models/Resource.js +++ b/ReactNativeClient/lib/models/Resource.js @@ -5,7 +5,7 @@ const Setting = require('lib/models/Setting.js'); const ArrayUtils = require('lib/ArrayUtils.js'); const pathUtils = require('lib/path-utils.js'); const { mime } = require('lib/mime-utils.js'); -const { filename } = require('lib/path-utils.js'); +const { filename, safeFilename } = require('lib/path-utils.js'); const { FsDriverDummy } = require('lib/fs-driver-dummy.js'); const markdownUtils = require('lib/markdownUtils'); const JoplinError = require('lib/JoplinError'); @@ -49,6 +49,15 @@ class Resource extends BaseItem { return resource.id + extension; } + static friendlyFilename(resource) { + let output = safeFilename(resource.title); // Make sure not to allow spaces or any special characters as it's not supported in HTTP headers + if (!output) output = resource.id; + let extension = resource.file_extension; + if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : ''; + extension = extension ? ('.' + extension) : ''; + return output + extension; + } + static fullPath(resource, encryptedBlob = false) { return Setting.value('resourceDir') + '/' + this.filename(resource, encryptedBlob); } diff --git a/ReactNativeClient/lib/services/rest/Api.js b/ReactNativeClient/lib/services/rest/Api.js index e6bf1523a..c8c94ed4d 100644 --- a/ReactNativeClient/lib/services/rest/Api.js +++ b/ReactNativeClient/lib/services/rest/Api.js @@ -12,6 +12,7 @@ const md5 = require('md5'); const { shim } = require('lib/shim'); const HtmlToMd = require('lib/HtmlToMd'); const { fileExtension, safeFileExtension, safeFilename, filename } = require('lib/path-utils'); +const ApiResponse = require('lib/services/rest/ApiResponse'); class ApiError extends Error { @@ -26,37 +27,10 @@ class ApiError extends Error { } -class ErrorMethodNotAllowed extends ApiError { - - constructor(message = 'Method Not Allowed') { - super(message, 405); - } - -} - -class ErrorNotFound extends ApiError { - - constructor(message = 'Not Found') { - super(message, 404); - } - -} - -class ErrorForbidden extends ApiError { - - constructor(message = 'Forbidden') { - super(message, 403); - } - -} - -class ErrorBadRequest extends ApiError { - - constructor(message = 'Bad Request') { - super(message, 400); - } - -} +class ErrorMethodNotAllowed extends ApiError { constructor(message = 'Method Not Allowed') { super(message, 405); } } +class ErrorNotFound extends ApiError { constructor(message = 'Not Found') { super(message, 404); } } +class ErrorForbidden extends ApiError { constructor(message = 'Forbidden') { super(message, 403); } } +class ErrorBadRequest extends ApiError { constructor(message = 'Bad Request') { super(message, 400); } } class Api { @@ -126,6 +100,8 @@ class Api { request.params = params; + if (!this[parsedPath.callName]) throw new ErrorNotFound(); + try { return this[parsedPath.callName](request, id, link); } catch (error) { @@ -288,6 +264,23 @@ class Api { // path: "C:\Users\Laurent\AppData\Local\Temp\BW77wkpP23iIGUstd0kDuXXC.jpg" // size: 164394 + if (request.method === 'GET') { + if (link !== 'file') throw new ErrorNotFound(); + + const resource = await Resource.load(id); + if (!resource) throw new ErrorNotFound(); + + const filePath = Resource.fullPath(resource); + const buffer = await shim.fsDriver().readFile(filePath, 'Buffer'); + + const response = new ApiResponse(); + response.type = 'attachment'; + response.body = buffer; + response.contentType = resource.mime; + response.attachmentFilename = Resource.friendlyFilename(resource); + return response; + } + if (request.method === 'POST') { if (!request.files.length) throw new ErrorBadRequest('Resource cannot be created without a file'); const filePath = request.files[0].path; diff --git a/ReactNativeClient/lib/services/rest/ApiResponse.js b/ReactNativeClient/lib/services/rest/ApiResponse.js new file mode 100644 index 000000000..eee58b02e --- /dev/null +++ b/ReactNativeClient/lib/services/rest/ApiResponse.js @@ -0,0 +1,7 @@ +class ApiResponse { + + constructor() {} + +} + +module.exports = ApiResponse; \ No newline at end of file