Add support for `DELETE` requests
authorAlexander Ebert <ebert@woltlab.com>
Tue, 12 Mar 2024 15:44:58 +0000 (16:44 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Tue, 12 Mar 2024 15:44:58 +0000 (16:44 +0100)
15 files changed:
com.woltlab.wcf/templates/accountSecurity.tpl
ts/WoltLabSuite/Core/Ajax/Backend.ts
ts/WoltLabSuite/Core/Api/Error.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Api/Result.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Api/Sessions/DeleteSession.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Core.ts
ts/WoltLabSuite/Core/Ui/Search/Page.ts
ts/WoltLabSuite/Core/Ui/User/Session/Delete.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js
wcfsetup/install/files/js/WoltLabSuite/Core/Api/Error.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Api/Result.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Api/Sessions/DeleteSession.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Session/Delete.js
wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
wcfsetup/install/files/lib/system/endpoint/controller/core/sessions/DeleteSession.class.php [new file with mode: 0644]

index 852e764dd6c73587f9d06738a88a1a131fe6d996..7786a5c8690e2d26aeb893c610f3b4b2a05dc2e9 100644 (file)
 </section>
 
 <script data-relocate="true">
-       require(['Language', 'WoltLabSuite/Core/Ui/User/Session/Delete'], function(Language, UserSessionDelete) {
-               Language.addObject({
-                       'wcf.user.security.deleteSession.confirmMessage': '{jslang}wcf.user.security.deleteSession.confirmMessage{/jslang}',
-               });
+       require(['WoltLabSuite/Core/Ui/User/Session/Delete'], ({ setup }) => {
+               {jsphrase name='wcf.user.security.deleteSession.confirmMessage'}
                
-               new (UserSessionDelete.default)();
+               setup();
        });
 </script>
 
index cc71f3e4971f8c03f781736929c8cad79a19b83c..5f0e612ce7ddb883bdae4a300f6feb0a1601b7ee 100644 (file)
@@ -19,6 +19,7 @@ import {
 import { extend, getXsrfToken } from "../Core";
 
 const enum RequestType {
+  DELETE,
   GET,
   POST,
 }
@@ -32,6 +33,10 @@ class SetupRequest {
     this.url = url;
   }
 
+  delete(): BackendRequest {
+    return new BackendRequest(this.url, RequestType.DELETE);
+  }
+
   get(): GetRequest {
     return new GetRequest(this.url, RequestType.GET);
   }
diff --git a/ts/WoltLabSuite/Core/Api/Error.ts b/ts/WoltLabSuite/Core/Api/Error.ts
new file mode 100644 (file)
index 0000000..b09ba26
--- /dev/null
@@ -0,0 +1,10 @@
+type RequestFailureType = "api_error" | "invalid_request_error";
+
+export class ApiError {
+  constructor(
+    public readonly type: RequestFailureType,
+    public readonly code: string,
+    public readonly message: string,
+    public readonly param: string,
+  ) {}
+}
diff --git a/ts/WoltLabSuite/Core/Api/Result.ts b/ts/WoltLabSuite/Core/Api/Result.ts
new file mode 100644 (file)
index 0000000..138e199
--- /dev/null
@@ -0,0 +1,69 @@
+import { StatusNotOk } from "../Ajax/Error";
+import { isPlainObject } from "../Core";
+import { ApiError } from "./Error";
+
+export type ApiResult<T> =
+  | {
+      ok: true;
+      value: T;
+      unwrap(): T;
+    }
+  | {
+      ok: false;
+      error: ApiError;
+      unwrap(): never;
+    };
+
+export function apiResultFromValue<T>(value: T): ApiResult<T> {
+  return {
+    ok: true,
+    value,
+    unwrap() {
+      return value;
+    },
+  };
+}
+
+export function apiResultFromError(error: ApiError): ApiResult<never> {
+  return {
+    ok: false,
+    error,
+    unwrap() {
+      throw error;
+    },
+  };
+}
+
+export async function apiResultFromStatusNotOk(e: StatusNotOk): Promise<ApiResult<never>> {
+  const { response } = e;
+
+  if (response === undefined) {
+    // Aborted requests do not have a return value.
+    throw e;
+  }
+
+  const contentType = response.headers.get("content-type");
+  if (!contentType || !contentType.includes("application/json")) {
+    throw e;
+  }
+
+  let json: unknown;
+  try {
+    json = await response.json();
+  } catch {
+    throw e;
+  }
+
+  if (
+    isPlainObject(json) &&
+    Object.hasOwn(json, "type") &&
+    (json.type === "api_error" || json.type === "invalid_request_error") &&
+    typeof json.code === "string" &&
+    typeof json.message === "string" &&
+    typeof json.param === "string"
+  ) {
+    return apiResultFromError(new ApiError(json.type, json.code, json.message, json.param));
+  }
+
+  throw e;
+}
diff --git a/ts/WoltLabSuite/Core/Api/Sessions/DeleteSession.ts b/ts/WoltLabSuite/Core/Api/Sessions/DeleteSession.ts
new file mode 100644 (file)
index 0000000..b7c37fe
--- /dev/null
@@ -0,0 +1,12 @@
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result";
+
+export async function deleteSession(sessionId: string): Promise<ApiResult<[]>> {
+  try {
+    await prepareRequest(`${window.WSC_API_URL}index.php?api/rpc/core/sessions/${sessionId}`).delete().fetchAsJson();
+  } catch (e) {
+    return apiResultFromError(e);
+  }
+
+  return apiResultFromValue([]);
+}
index 247fe203f1a16c5a9bd2b77f8ce0f4c0087652b9..d76a8bb70d21d3db8d085d48290ed3fed50a46f2 100644 (file)
@@ -143,7 +143,7 @@ export function inherit(constructor: new () => any, superConstructor: new () =>
 /**
  * Returns true if `obj` is an object literal.
  */
-export function isPlainObject(obj: unknown): boolean {
+export function isPlainObject(obj: unknown): obj is Record<string, unknown> {
   if (typeof obj !== "object" || obj === null) {
     return false;
   }
index 712a48fdbb82bde950d0fd6532525b4380b4d4c3..663c637a1e35dadff37ffb3bf9a8b7e13bdaef28 100644 (file)
@@ -24,7 +24,7 @@ function click(event: MouseEvent): void {
     const data = JSON.parse(target.dataset.parameters || "");
     if (Core.isPlainObject(data)) {
       Object.keys(data).forEach((key) => {
-        parameters.set(key, data[key]);
+        parameters.set(key, data[key] as string);
       });
     }
   } catch (e) {
index ffda6f24e76be8ecf81fc589e2dbcdd9f0c2155c..7c241e8337fa4c591a7e1fc40ae1d398a74115fd 100644 (file)
@@ -7,73 +7,26 @@
  * @woltlabExcludeBundle all
  */
 
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../../Ajax/Data";
 import * as UiNotification from "../../Notification";
 import * as UiConfirmation from "../../Confirmation";
 import * as Language from "../../../Language";
-import * as Core from "../../../Core";
+import { deleteSession } from "WoltLabSuite/Core/Api/Sessions/DeleteSession";
 
-export class UiUserSessionDelete implements AjaxCallbackObject {
-  private readonly knownElements = new Map<string, HTMLElement>();
+function onClick(button: HTMLElement): void {
+  UiConfirmation.show({
+    message: Language.get("wcf.user.security.deleteSession.confirmMessage"),
+    confirm: async (_parameters) => {
+      (await deleteSession(button.dataset.sessionId!)).unwrap();
 
-  /**
-   * Initializes the session delete buttons.
-   */
-  constructor() {
-    document.querySelectorAll(".sessionDeleteButton").forEach((element: HTMLElement) => {
-      if (!element.dataset.sessionId) {
-        throw new Error(`No sessionId for session delete button given.`);
-      }
+      button.closest("li")?.remove();
 
-      if (!this.knownElements.has(element.dataset.sessionId)) {
-        element.addEventListener("click", (ev) => this.delete(element, ev));
-
-        this.knownElements.set(element.dataset.sessionId, element);
-      }
-    });
-  }
-
-  /**
-   * Opens the user trophy list for a specific user.
-   */
-  private delete(element: HTMLElement, event: MouseEvent): void {
-    event.preventDefault();
-
-    UiConfirmation.show({
-      message: Language.get("wcf.user.security.deleteSession.confirmMessage"),
-      confirm: (_parameters) => {
-        Ajax.api(this, {
-          t: Core.getXsrfToken(),
-          sessionID: element.dataset.sessionId,
-        });
-      },
-    });
-  }
-
-  _ajaxSuccess(data: AjaxResponse): void {
-    const element = this.knownElements.get(data.sessionID);
-
-    if (element !== undefined) {
-      const sessionItem = element.closest("li");
-
-      if (sessionItem !== null) {
-        sessionItem.remove();
-      }
-    }
-
-    UiNotification.show();
-  }
-
-  _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
-    return {
-      url: "index.php?delete-session/",
-    };
-  }
+      UiNotification.show();
+    },
+  });
 }
 
-export default UiUserSessionDelete;
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
-  sessionID: string;
+export function setup() {
+  document.querySelectorAll(".sessionDeleteButton").forEach((element: HTMLElement) => {
+    element.addEventListener("click", () => onClick(element));
+  });
 }
index aeb976768db6451ad15dd06c444c8222d3ccda94..38b61308d51a6d3003f0c147b6bfa805d21f4a54 100644 (file)
@@ -16,11 +16,14 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi
         constructor(url) {
             this.url = url;
         }
+        delete() {
+            return new BackendRequest(this.url, 0 /* RequestType.DELETE */);
+        }
         get() {
-            return new GetRequest(this.url, 0 /* RequestType.GET */);
+            return new GetRequest(this.url, 1 /* RequestType.GET */);
         }
         post(payload) {
-            return new BackendRequest(this.url, 1 /* RequestType.POST */, payload);
+            return new BackendRequest(this.url, 2 /* RequestType.POST */, payload);
         }
     }
     let ignoreConnectionErrors = false;
@@ -89,7 +92,7 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi
                 cache: this.#allowCaching ? "default" : "no-store",
                 redirect: "error",
             }, requestOptions);
-            if (this.#type === 1 /* RequestType.POST */) {
+            if (this.#type === 2 /* RequestType.POST */) {
                 init.method = "POST";
                 if (this.#payload) {
                     if (this.#payload instanceof FormData) {
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Error.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Error.js
new file mode 100644 (file)
index 0000000..cb571bb
--- /dev/null
@@ -0,0 +1,18 @@
+define(["require", "exports"], function (require, exports) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.ApiError = void 0;
+    class ApiError {
+        type;
+        code;
+        message;
+        param;
+        constructor(type, code, message, param) {
+            this.type = type;
+            this.code = code;
+            this.message = message;
+            this.param = param;
+        }
+    }
+    exports.ApiError = ApiError;
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Result.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Result.js
new file mode 100644 (file)
index 0000000..76fa08c
--- /dev/null
@@ -0,0 +1,53 @@
+define(["require", "exports", "../Core", "./Error"], function (require, exports, Core_1, Error_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.apiResultFromStatusNotOk = exports.apiResultFromError = exports.apiResultFromValue = void 0;
+    function apiResultFromValue(value) {
+        return {
+            ok: true,
+            value,
+            unwrap() {
+                return value;
+            },
+        };
+    }
+    exports.apiResultFromValue = apiResultFromValue;
+    function apiResultFromError(error) {
+        return {
+            ok: false,
+            error,
+            unwrap() {
+                throw error;
+            },
+        };
+    }
+    exports.apiResultFromError = apiResultFromError;
+    async function apiResultFromStatusNotOk(e) {
+        const { response } = e;
+        if (response === undefined) {
+            // Aborted requests do not have a return value.
+            throw e;
+        }
+        const contentType = response.headers.get("content-type");
+        if (!contentType || !contentType.includes("application/json")) {
+            throw e;
+        }
+        let json;
+        try {
+            json = await response.json();
+        }
+        catch {
+            throw e;
+        }
+        if ((0, Core_1.isPlainObject)(json) &&
+            Object.hasOwn(json, "type") &&
+            (json.type === "api_error" || json.type === "invalid_request_error") &&
+            typeof json.code === "string" &&
+            typeof json.message === "string" &&
+            typeof json.param === "string") {
+            return apiResultFromError(new Error_1.ApiError(json.type, json.code, json.message, json.param));
+        }
+        throw e;
+    }
+    exports.apiResultFromStatusNotOk = apiResultFromStatusNotOk;
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Sessions/DeleteSession.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Sessions/DeleteSession.js
new file mode 100644 (file)
index 0000000..5df2e89
--- /dev/null
@@ -0,0 +1,15 @@
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.deleteSession = void 0;
+    async function deleteSession(sessionId) {
+        try {
+            await (0, Backend_1.prepareRequest)(`${window.WSC_API_URL}index.php?api/rpc/core/sessions/${sessionId}`).delete().fetchAsJson();
+        }
+        catch (e) {
+            return (0, Result_1.apiResultFromError)(e);
+        }
+        return (0, Result_1.apiResultFromValue)([]);
+    }
+    exports.deleteSession = deleteSession;
+});
index 97d52d61a33c6ddae9d2f9a4ecde90f55885930a..6c1aa6e892a94214d529c2f72aa6dc02df5151c5 100644 (file)
@@ -6,62 +6,27 @@
  * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  * @woltlabExcludeBundle all
  */
-define(["require", "exports", "tslib", "../../../Ajax", "../../Notification", "../../Confirmation", "../../../Language", "../../../Core"], function (require, exports, tslib_1, Ajax, UiNotification, UiConfirmation, Language, Core) {
+define(["require", "exports", "tslib", "../../Notification", "../../Confirmation", "../../../Language", "WoltLabSuite/Core/Api/Sessions/DeleteSession"], function (require, exports, tslib_1, UiNotification, UiConfirmation, Language, DeleteSession_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
-    exports.UiUserSessionDelete = void 0;
-    Ajax = tslib_1.__importStar(Ajax);
+    exports.setup = void 0;
     UiNotification = tslib_1.__importStar(UiNotification);
     UiConfirmation = tslib_1.__importStar(UiConfirmation);
     Language = tslib_1.__importStar(Language);
-    Core = tslib_1.__importStar(Core);
-    class UiUserSessionDelete {
-        knownElements = new Map();
-        /**
-         * Initializes the session delete buttons.
-         */
-        constructor() {
-            document.querySelectorAll(".sessionDeleteButton").forEach((element) => {
-                if (!element.dataset.sessionId) {
-                    throw new Error(`No sessionId for session delete button given.`);
-                }
-                if (!this.knownElements.has(element.dataset.sessionId)) {
-                    element.addEventListener("click", (ev) => this.delete(element, ev));
-                    this.knownElements.set(element.dataset.sessionId, element);
-                }
-            });
-        }
-        /**
-         * Opens the user trophy list for a specific user.
-         */
-        delete(element, event) {
-            event.preventDefault();
-            UiConfirmation.show({
-                message: Language.get("wcf.user.security.deleteSession.confirmMessage"),
-                confirm: (_parameters) => {
-                    Ajax.api(this, {
-                        t: Core.getXsrfToken(),
-                        sessionID: element.dataset.sessionId,
-                    });
-                },
-            });
-        }
-        _ajaxSuccess(data) {
-            const element = this.knownElements.get(data.sessionID);
-            if (element !== undefined) {
-                const sessionItem = element.closest("li");
-                if (sessionItem !== null) {
-                    sessionItem.remove();
-                }
-            }
-            UiNotification.show();
-        }
-        _ajaxSetup() {
-            return {
-                url: "index.php?delete-session/",
-            };
-        }
+    function onClick(button) {
+        UiConfirmation.show({
+            message: Language.get("wcf.user.security.deleteSession.confirmMessage"),
+            confirm: async (_parameters) => {
+                (await (0, DeleteSession_1.deleteSession)(button.dataset.sessionId)).unwrap();
+                button.closest("li")?.remove();
+                UiNotification.show();
+            },
+        });
     }
-    exports.UiUserSessionDelete = UiUserSessionDelete;
-    exports.default = UiUserSessionDelete;
+    function setup() {
+        document.querySelectorAll(".sessionDeleteButton").forEach((element) => {
+            element.addEventListener("click", () => onClick(element));
+        });
+    }
+    exports.setup = setup;
 });
index c085a92292f05b73080cc3efa0178709d71c4dda..3f6fb50bbb9f776bd5bedcefce701521c61fe9ef 100644 (file)
@@ -86,6 +86,7 @@ return static function (): void {
 
     $eventHandler->register(ControllerCollecting::class, static function (ControllerCollecting $event) {
         $event->register(new \wcf\system\endpoint\controller\core\messages\MentionSuggestions);
+        $event->register(new \wcf\system\endpoint\controller\core\sessions\DeleteSession);
     });
 
     try {
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/sessions/DeleteSession.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/sessions/DeleteSession.class.php
new file mode 100644 (file)
index 0000000..aabf40b
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+namespace wcf\system\endpoint\controller\core\sessions;
+
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use wcf\system\endpoint\DeleteRequest;
+use wcf\system\endpoint\IController;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\session\SessionHandler;
+use wcf\system\WCF;
+
+#[DeleteRequest('/core/sessions/{id}')]
+final class DeleteSession implements IController
+{
+    #[\Override]
+    public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+    {
+        $sessionID = $variables['id'];
+
+        if (!$this->isOwnSessionID($sessionID)) {
+            throw new IllegalLinkException();
+        }
+
+        SessionHandler::getInstance()->deleteUserSession($sessionID);
+
+        return new JsonResponse([]);
+    }
+
+    private function isOwnSessionID(string $sessionID): bool
+    {
+        foreach (SessionHandler::getInstance()->getUserSessions(WCF::getUser()) as $session) {
+            if ($session->getSessionID() === $sessionID) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}