diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e6d80d9..af45648 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -207,7 +207,7 @@ paths: ^2c7cfa ``` - ## Append Content Below a Heading + ## Append, Prepend, or Replace Content Below a Heading If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: @@ -220,7 +220,7 @@ paths: The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - ## Append Content to a Block Reference + ## Append, Prepend, or Replace Content to a Block Reference If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: @@ -233,7 +233,7 @@ paths: The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - ## Add a Row to a Table Referenced by a Block Reference + ## Append, Prepend, or Replace a Row or Rows to/in a Table Referenced by a Block Reference If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following @@ -666,7 +666,7 @@ paths: ^2c7cfa ``` - ## Append Content Below a Heading + ## Append, Prepend, or Replace Content Below a Heading If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: @@ -679,7 +679,7 @@ paths: The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - ## Append Content to a Block Reference + ## Append, Prepend, or Replace Content to a Block Reference If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: @@ -692,7 +692,7 @@ paths: The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - ## Add a Row to a Table Referenced by a Block Reference + ## Append, Prepend, or Replace a Row or Rows to/in a Table Referenced by a Block Reference If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following @@ -1104,7 +1104,7 @@ paths: ^2c7cfa ``` - ## Append Content Below a Heading + ## Append, Prepend, or Replace Content Below a Heading If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: @@ -1117,7 +1117,7 @@ paths: The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - ## Append Content to a Block Reference + ## Append, Prepend, or Replace Content to a Block Reference If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: @@ -1130,7 +1130,7 @@ paths: The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - ## Add a Row to a Table Referenced by a Block Reference + ## Append, Prepend, or Replace a Row or Rows to/in a Table Referenced by a Block Reference If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following @@ -1668,6 +1668,8 @@ paths: - "Vault Files" get: description: | + **Note:** This path also supports the WebDAV-style `MOVE` method for moving files. Specify the destination path in a `Destination` header. The MOVE method is not displayed in Swagger UI due to OpenAPI spec limitations, but is fully functional. See the raw openapi.yaml for complete MOVE documentation. + Returns the content of the file at the specified path in your vault should the file exist. If you specify the header `Accept: application/vnd.olrapi.note+json`, will return a JSON representation of your note including parsed tag and frontmatter data as well as filesystem metadata. See "responses" below for details. @@ -1700,6 +1702,73 @@ paths: Return the content of a single file in your vault. tags: - "Vault Files" + move: + description: | + Moves a file from its current location to a new location specified in the Destination header. This operation preserves file history and updates internal links. The destination path must be provided in the Destination header following WebDAV conventions. + parameters: + - description: | + The new path for the file (relative to your vault root). Path must not contain ".." or start with "/" for security reasons. + in: "header" + name: "Destination" + required: true + schema: + format: "path" + type: "string" + - description: | + Path to the relevant file (relative to your vault root). + in: "path" + name: "filename" + required: true + schema: + format: "path" + type: "string" + responses: + "201": + content: + application/json: + schema: + properties: + message: + example: "File successfully moved" + type: "string" + newPath: + example: "another-folder/file.md" + type: "string" + oldPath: + example: "folder/file.md" + type: "string" + type: "object" + description: "File successfully moved" + "400": + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + description: | + Bad request - Missing Destination header, invalid destination path, or path traversal attempt. + "404": + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + description: "Source file does not exist." + "405": + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + description: | + Your path references a directory instead of a file; this request method is valid only for files. + "409": + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + description: "Destination file already exists." + summary: | + Move a file to a new location in your vault. + tags: + - "Vault Files" patch: description: | Inserts content into an existing note relative to a heading, block refeerence, or frontmatter field within that document. @@ -1760,7 +1829,7 @@ paths: ^2c7cfa ``` - ## Append Content Below a Heading + ## Append, Prepend, or Replace Content Below a Heading If you wanted to append the content "Hello" below "Subheading 1:1:1" under "Heading 1", you could send a request with the following headers: @@ -1773,7 +1842,7 @@ paths: The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - ## Append Content to a Block Reference + ## Append, Prepend, or Replace Content to a Block Reference If you wanted to append the content "Hello" below the block referenced by "2d9b4a" above ("More random text."), you could send the following headers: @@ -1786,7 +1855,7 @@ paths: The above would work just fine for `prepend` or `replace`, too, of course, but with different results. - ## Add a Row to a Table Referenced by a Block Reference + ## Append, Prepend, or Replace a Row or Rows to/in a Table Referenced by a Block Reference If you wanted to add a new city ("Chicago, IL") and population ("16") pair to the table above referenced by the block reference `2c7cfa`, you could send the following diff --git a/docs/src/lib/move.jsonnet b/docs/src/lib/move.jsonnet new file mode 100644 index 0000000..8aa9295 --- /dev/null +++ b/docs/src/lib/move.jsonnet @@ -0,0 +1,85 @@ +{ + tags: [ + 'Vault Files', + ], + summary: 'Move a file to a new location in your vault.\n', + description: 'Moves a file from its current location to a new location specified in the Destination header. This operation preserves file history and updates internal links. The destination path must be provided in the Destination header following WebDAV conventions.\n', + parameters: [ + { + name: 'Destination', + 'in': 'header', + description: 'The new path for the file (relative to your vault root). Path must not contain ".." or start with "/" for security reasons.\n', + required: true, + schema: { + type: 'string', + format: 'path', + }, + }, + ], + responses: { + '201': { + description: 'File successfully moved', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'File successfully moved', + }, + oldPath: { + type: 'string', + example: 'folder/file.md', + }, + newPath: { + type: 'string', + example: 'another-folder/file.md', + }, + }, + }, + }, + }, + }, + '400': { + description: 'Bad request - Missing Destination header, invalid destination path, or path traversal attempt.\n', + content: { + 'application/json': { + schema: { + '$ref': '#/components/schemas/Error', + }, + }, + }, + }, + '404': { + description: 'Source file does not exist.', + content: { + 'application/json': { + schema: { + '$ref': '#/components/schemas/Error', + }, + }, + }, + }, + '405': { + description: 'Your path references a directory instead of a file; this request method is valid only for files.\n', + content: { + 'application/json': { + schema: { + '$ref': '#/components/schemas/Error', + }, + }, + }, + }, + '409': { + description: 'Destination file already exists.', + content: { + 'application/json': { + schema: { + '$ref': '#/components/schemas/Error', + }, + }, + }, + }, + }, +} diff --git a/docs/src/openapi.jsonnet b/docs/src/openapi.jsonnet index ebf6f47..90a80b2 100644 --- a/docs/src/openapi.jsonnet +++ b/docs/src/openapi.jsonnet @@ -1,14 +1,15 @@ -local Delete = import 'delete.jsonnet'; -local Get = import 'get.jsonnet'; -local Patch = import 'patch.jsonnet'; -local Post = import 'post.jsonnet'; -local Put = import 'put.jsonnet'; +local Delete = import 'lib/delete.jsonnet'; +local Get = import 'lib/get.jsonnet'; +local Move = import 'lib/move.jsonnet'; +local Patch = import 'lib/patch.jsonnet'; +local Post = import 'lib/post.jsonnet'; +local Put = import 'lib/put.jsonnet'; -local ParamDay = import 'day.param.jsonnet'; -local ParamMonth = import 'month.param.jsonnet'; -local ParamPath = import 'path.param.jsonnet'; -local ParamPeriod = import 'period.param.jsonnet'; -local ParamYear = import 'year.param.jsonnet'; +local ParamDay = import 'lib/day.param.jsonnet'; +local ParamMonth = import 'lib/month.param.jsonnet'; +local ParamPath = import 'lib/path.param.jsonnet'; +local ParamPeriod = import 'lib/period.param.jsonnet'; +local ParamYear = import 'lib/year.param.jsonnet'; std.manifestYamlDoc( @@ -166,7 +167,7 @@ std.manifestYamlDoc( 'Vault Files', ], summary: 'Return the content of a single file in your vault.\n', - description: 'Returns the content of the file at the specified path in your vault should the file exist.\n\nIf you specify the header `Accept: application/vnd.olrapi.note+json`, will return a JSON representation of your note including parsed tag and frontmatter data as well as filesystem metadata. See "responses" below for details.\n', + description: '**Note:** This path also supports the WebDAV-style `MOVE` method for moving files. Specify the destination path in a `Destination` header. The MOVE method is not displayed in Swagger UI due to OpenAPI spec limitations, but is fully functional. See the raw openapi.yaml for complete MOVE documentation.\n\nReturns the content of the file at the specified path in your vault should the file exist.\n\nIf you specify the header `Accept: application/vnd.olrapi.note+json`, will return a JSON representation of your note including parsed tag and frontmatter data as well as filesystem metadata. See "responses" below for details.\n', parameters+: [ParamPath], }, put: Put { @@ -193,6 +194,9 @@ std.manifestYamlDoc( description: 'Inserts content into an existing note relative to a heading, block refeerence, or frontmatter field within that document.\n\n' + Patch.description, parameters+: [ParamPath], }, + move: Move { + parameters: Move.parameters + [ParamPath], + }, delete: Delete { tags: [ 'Vault Files', diff --git a/src/constants.ts b/src/constants.ts index 0551126..a04e949 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -45,6 +45,16 @@ export const ERROR_CODE_MESSAGES: Record = { [ErrorCode.InvalidSearch]: "The search query you provided is not valid.", [ErrorCode.ErrorPreparingSimpleSearch]: "Error encountered while calling Obsidian `prepareSimpleSearch` API.", + [ErrorCode.MissingDestinationHeader]: + "Destination header is required for MOVE operations.", + [ErrorCode.InvalidDestinationPath]: + "Destination path must be a file path, not a directory.", + [ErrorCode.PathTraversalNotAllowed]: + "Path traversal is not allowed. Paths must be relative and within the vault.", + [ErrorCode.DestinationAlreadyExists]: + "Destination file already exists.", + [ErrorCode.FileOperationFailed]: + "File operation failed. Check the error message for details.", }; export enum ContentTypes { diff --git a/src/requestHandler.test.ts b/src/requestHandler.test.ts index 9638726..3a70775 100644 --- a/src/requestHandler.test.ts +++ b/src/requestHandler.test.ts @@ -1,16 +1,16 @@ import http from "http"; import request from "supertest"; -import RequestHandler from "./requestHandler"; -import { LocalRestApiSettings } from "./types"; -import { CERT_NAME } from "./constants"; import { App, - TFile, Command, HeadingCache, PluginManifest, + TFile, } from "../mocks/obsidian"; +import { CERT_NAME } from "./constants"; +import RequestHandler from "./requestHandler"; +import { LocalRestApiSettings } from "./types"; describe("requestHandler", () => { const API_KEY = "my api key"; @@ -740,6 +740,158 @@ describe("requestHandler", () => { ); }); }); + + describe("file move operation", () => { + test("successful move with Destination header", async () => { + const oldPath = "folder/file.md"; + const newPath = "another-folder/subfolder/file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + app.vault.adapter._exists = false; // destination doesn't exist + + // Mock fileManager and createFolder + (app as any).fileManager = { + renameFile: jest.fn().mockResolvedValue(undefined) + }; + app.vault.createFolder = jest.fn().mockResolvedValue(undefined); + + const response = await request(server) + .move(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Destination", newPath) + .expect(201); + + expect(response.body.message).toEqual("File successfully moved"); + expect(response.body.oldPath).toEqual(oldPath); + expect(response.body.newPath).toEqual(newPath); + expect(app.vault.createFolder).toHaveBeenCalledWith("another-folder/subfolder"); + expect((app as any).fileManager.renameFile).toHaveBeenCalledWith(mockFile, newPath); + }); + + test("move fails with non-existent file", async () => { + // Mock file doesn't exist + app.vault._getAbstractFileByPath = null; + + await request(server) + .move("/vault/non-existent.md") + .set("Authorization", `Bearer ${API_KEY}`) + .set("Destination", "new-location/file.md") + .expect(404); + }); + + test("move fails when destination exists", async () => { + const oldPath = "folder/file.md"; + const newPath = "another-folder/existing-file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + app.vault.adapter._exists = true; // destination already exists + + const response = await request(server) + .move(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Destination", newPath) + .expect(409); + + expect(response.body.message).toContain("Destination file already exists"); + }); + + test("move fails with missing Destination header", async () => { + const oldPath = "folder/file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + + const response = await request(server) + .move(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .expect(400); + + expect(response.body.message).toContain("Destination header is required"); + }); + + test("move fails when destination path is a directory", async () => { + const oldPath = "folder/file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + + const response = await request(server) + .move(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Destination", "new-folder/") + .expect(400); + + expect(response.body.message).toContain("Destination path must be a file path"); + }); + + test("move to root directory", async () => { + const oldPath = "deep/nested/folder/file.md"; + const newPath = "file.md"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + app.vault.adapter._exists = false; // destination doesn't exist + + // Mock fileManager + (app as any).fileManager = { + renameFile: jest.fn().mockResolvedValue(undefined) + }; + app.vault.createFolder = jest.fn(); + + const response = await request(server) + .move(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Destination", newPath) + .expect(201); + + expect(response.body.message).toEqual("File successfully moved"); + expect(app.vault.createFolder).not.toHaveBeenCalled(); // No need to create parent for root + expect((app as any).fileManager.renameFile).toHaveBeenCalledWith(mockFile, newPath); + }); + + test("move fails with path traversal attempt", async () => { + const oldPath = "folder/file.md"; + const maliciousPath = "../../../etc/passwd"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + + const response = await request(server) + .move(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Destination", maliciousPath) + .expect(400); + + expect(response.body.errorCode).toEqual(40003); + expect(response.body.message).toContain("Path traversal is not allowed"); + }); + + test("move fails with absolute path", async () => { + const oldPath = "folder/file.md"; + const absolutePath = "/etc/passwd"; + + // Mock file exists + const mockFile = new TFile(); + app.vault._getAbstractFileByPath = mockFile; + + const response = await request(server) + .move(`/vault/${oldPath}`) + .set("Authorization", `Bearer ${API_KEY}`) + .set("Destination", absolutePath) + .expect(400); + + expect(response.body.errorCode).toEqual(40003); + expect(response.body.message).toContain("Path traversal is not allowed"); + }); + }); }); describe("commandGet", () => { diff --git a/src/requestHandler.ts b/src/requestHandler.ts index 106183b..4a4b1a1 100644 --- a/src/requestHandler.ts +++ b/src/requestHandler.ts @@ -1,3 +1,4 @@ +import forge from "node-forge"; import { apiVersion, App, @@ -9,18 +10,13 @@ import { } from "obsidian"; import periodicNotes from "obsidian-daily-notes-interface"; import { getAPI as getDataviewAPI } from "obsidian-dataview"; -import forge from "node-forge"; +import bodyParser from "body-parser"; +import cors from "cors"; import express from "express"; +import WildcardRegexp from "glob-to-regexp"; import http from "http"; -import cors from "cors"; -import mime from "mime-types"; -import bodyParser from "body-parser"; import jsonLogic from "json-logic-js"; -import responseTime from "response-time"; -import queryString from "query-string"; -import WildcardRegexp from "glob-to-regexp"; -import path from "path"; import { applyPatch, ContentType, @@ -29,7 +25,18 @@ import { PatchOperation, PatchTargetType, } from "markdown-patch"; +import mime from "mime-types"; +import path from "path"; +import queryString from "query-string"; +import responseTime from "response-time"; +import LocalRestApiPublicApi from "./api"; +import { + CERT_NAME, + ContentTypes, + ERROR_CODE_MESSAGES, + MaximumRequestSize, +} from "./constants"; import { CannedResponse, ErrorCode, @@ -48,13 +55,6 @@ import { getSplicePosition, toArrayBuffer, } from "./utils"; -import { - CERT_NAME, - ContentTypes, - ERROR_CODE_MESSAGES, - MaximumRequestSize, -} from "./constants"; -import LocalRestApiPublicApi from "./api"; // Import openapi.yaml as a string import openapiYaml from "../docs/openapi.yaml"; @@ -262,10 +262,10 @@ export default class RequestHandler { certificateInfo: this.requestIsAuthenticated(req) && certificate ? { - validityDays: getCertificateValidityDays(certificate), - regenerateRecommended: - !getCertificateIsUptoStandards(certificate), - } + validityDays: getCertificateValidityDays(certificate), + regenerateRecommended: + !getCertificateIsUptoStandards(certificate), + } : undefined, apiExtensions: this.requestIsAuthenticated(req) ? this.apiExtensions.map(({ manifest }) => manifest) @@ -509,18 +509,16 @@ export default class RequestHandler { }); return; } + + // Only these target types are valid for PATCH if (!["heading", "block", "frontmatter"].includes(targetType)) { this.returnCannedResponse(res, { errorCode: ErrorCode.InvalidTargetTypeHeader, }); return; } - if (!operation) { - this.returnCannedResponse(res, { - errorCode: ErrorCode.MissingOperation, - }); - return; - } + + // Validate operations for applyPatch target types if (!["append", "prepend", "replace"].includes(operation)) { this.returnCannedResponse(res, { errorCode: ErrorCode.InvalidOperation, @@ -674,6 +672,98 @@ export default class RequestHandler { return this._vaultDelete(path, req, res); } + async _vaultMove( + path: string, + req: express.Request, + res: express.Response + ): Promise { + + if (!path || path.endsWith("/")) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.RequestMethodValidOnlyForFiles, + }); + return; + } + + // For WebDAV-style MOVE, the new path should be in the Destination header + const rawDestination = req.get('Destination'); + + if (!rawDestination) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.MissingDestinationHeader, + }); + return; + } + + const rawNewPath = decodeURIComponent(rawDestination.trim()); + + // Check for path traversal attempts + if (rawNewPath.includes('..') || rawNewPath.startsWith('/')) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.PathTraversalNotAllowed, + }); + return; + } + + // Validate new path is not a directory + if (rawNewPath.endsWith("/")) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.InvalidDestinationPath, + }); + return; + } + + // Normalize the new path + const newPath = rawNewPath.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\/|\/$/g, ''); + + // Check if source file exists + const sourceFile = this.app.vault.getAbstractFileByPath(path); + if (!sourceFile || !(sourceFile instanceof TFile)) { + this.returnCannedResponse(res, { statusCode: 404 }); + return; + } + + // Check if destination already exists + const destExists = await this.app.vault.adapter.exists(newPath); + if (destExists) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.DestinationAlreadyExists, + }); + return; + } + + try { + // Create parent directories if needed + const parentDir = newPath.substring(0, newPath.lastIndexOf('/')); + if (parentDir && !await this.app.vault.adapter.exists(parentDir)) { + await this.app.vault.createFolder(parentDir); + } + + // Use FileManager to move the file (preserves history and updates links) + // @ts-ignore - fileManager exists at runtime but not in type definitions + await this.app.fileManager.renameFile(sourceFile, newPath); + + res.status(201).json({ + message: "File successfully moved", + oldPath: path, + newPath: newPath + }); + } catch (error) { + this.returnCannedResponse(res, { + errorCode: ErrorCode.FileOperationFailed, + message: `Failed to move file: ${error.message}` + }); + } + } + + async vaultMove(req: express.Request, res: express.Response): Promise { + const path = decodeURIComponent( + req.path.slice(req.path.indexOf("/", 1) + 1) + ); + + return this._vaultMove(path, req, res); + } + getPeriodicNoteInterface(): Record { return { daily: { @@ -1268,6 +1358,14 @@ export default class RequestHandler { .post(this.vaultPost.bind(this)) .delete(this.vaultDelete.bind(this)); + // WebDAV-style MOVE method + this.api.route("/vault/*").all((req, res, next) => { + if (req.method === "MOVE") { + return this.vaultMove(req, res); + } + next(); + }); + this.api .route("/periodic/:period/") .get(this.periodicGet.bind(this)) diff --git a/src/types.ts b/src/types.ts index f6e035d..ea50da1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,11 @@ export enum ErrorCode { PeriodDoesNotExist = 40460, PeriodicNoteDoesNotExist = 40461, RequestMethodValidOnlyForFiles = 40510, + MissingDestinationHeader = 40001, + InvalidDestinationPath = 40002, + PathTraversalNotAllowed = 40003, + DestinationAlreadyExists = 40901, + FileOperationFailed = 50001, ErrorPreparingSimpleSearch = 50010, }