Add custom path prefix support
authorAdam Dullage <redacted>
Fri, 14 Jun 2024 07:25:51 +0000 (08:25 +0100)
committerAdam Dullage <redacted>
Fri, 14 Jun 2024 07:25:51 +0000 (08:25 +0100)
client/api.js
client/index.html
client/router.js
client/site.webmanifest
client/style.css
client/tokenStorage.js
server/attachments/file_system/file_system.py
server/global_config.py
server/helpers.py
server/main.py
vite.config.js

index 11a3731f8f0341dce641eb6281bdbef8b00a70ad..4657f9809e3468de62ca6ba2a55d0ba69d4cae62 100644 (file)
@@ -12,7 +12,7 @@ const api = axios.create();
 api.interceptors.request.use(
   // If the request is not for the token endpoint, add the token to the headers.
   function (config) {
-    if (config.url !== "/api/token") {
+    if (config.url !== "api/token") {
       const token = getStoredToken();
       config.headers.Authorization = `Bearer ${token}`;
     }
@@ -44,7 +44,7 @@ export function apiErrorHandler(error, toast) {
 
 export async function getConfig() {
   try {
-    const response = await api.get("/api/config");
+    const response = await api.get("api/config");
     return response.data;
   } catch (error) {
     return Promise.reject(error);
@@ -53,7 +53,7 @@ export async function getConfig() {
 
 export async function getToken(username, password, totp) {
   try {
-    const response = await api.post("/api/token", {
+    const response = await api.post("api/token", {
       username: username,
       password: totp ? password + totp : password,
     });
@@ -65,7 +65,7 @@ export async function getToken(username, password, totp) {
 
 export async function getNotes(term, sort, order, limit) {
   try {
-    const response = await api.get("/api/search", {
+    const response = await api.get("api/search", {
       params: {
         term: term,
         sort: sort,
@@ -81,7 +81,7 @@ export async function getNotes(term, sort, order, limit) {
 
 export async function createNote(title, content) {
   try {
-    const response = await api.post("/api/notes", {
+    const response = await api.post("api/notes", {
       title: title,
       content: content,
     });
@@ -93,7 +93,7 @@ export async function createNote(title, content) {
 
 export async function getNote(title) {
   try {
-    const response = await api.get(`/api/notes/${title}`);
+    const response = await api.get(`api/notes/${title}`);
     return new Note(response.data);
   } catch (response) {
     return Promise.reject(response);
@@ -102,7 +102,7 @@ export async function getNote(title) {
 
 export async function updateNote(title, newTitle, newContent) {
   try {
-    const response = await api.patch(`/api/notes/${title}`, {
+    const response = await api.patch(`api/notes/${title}`, {
       newTitle: newTitle,
       newContent: newContent,
     });
@@ -114,7 +114,7 @@ export async function updateNote(title, newTitle, newContent) {
 
 export async function deleteNote(title) {
   try {
-    await api.delete(`/api/notes/${title}`);
+    await api.delete(`api/notes/${title}`);
   } catch (response) {
     return Promise.reject(response);
   }
@@ -122,7 +122,7 @@ export async function deleteNote(title) {
 
 export async function getTags() {
   try {
-    const response = await api.get("/api/tags");
+    const response = await api.get("api/tags");
     return response.data;
   } catch (response) {
     return Promise.reject(response);
@@ -133,7 +133,7 @@ export async function createAttachment(file) {
   try {
     const formData = new FormData();
     formData.append("file", file);
-    const response = await api.post("/api/attachments", formData, {
+    const response = await api.post("api/attachments", formData, {
       headers: {
         "Content-Type": "multipart/form-data",
       },
index 59f9d04195660f69e23bbebb8dde0573731d0206..9bf1065d0ff2412e6dc7071efb43d75ef476104c 100644 (file)
@@ -8,6 +8,7 @@
       content="width=device-width, initial-scale=1, shrink-to-fit=no"\r
     />\r
 \r
+    <base href="/" />\r
     <link\r
       rel="apple-touch-icon"\r
       sizes="180x180"\r
       href="assets/favicon-16x16.png"\r
     />\r
     <link rel="manifest" href="site.webmanifest" />\r
-    <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#F8A66B" />\r
+    <link rel="mask-icon" href="safari-pinned-tab.svg" color="#F8A66B" />\r
     <link rel="shortcut icon" href="assets/favicon.ico" />\r
     <meta name="theme-color" content="#F8A66B" />\r
-    <link href="/style.css" rel="stylesheet" />\r
+    <link href="style.css" rel="stylesheet" />\r
 \r
     <title>flatnotes</title>\r
   </head>\r
   <body class="bg-theme-background text-theme-text">\r
     <div id="app"></div>\r
-    <script type="module" src="/index.js"></script>\r
+    <script type="module" src="index.js"></script>\r
   </body>\r
 </html>\r
index 07b6c9d38191ede73edcae595af009071c8b46ea..81b2314b3c62e9fd1bb31124c024e625a0640b42 100644 (file)
@@ -3,7 +3,7 @@ import * as constants from "./constants.js";
 import { createRouter, createWebHistory } from "vue-router";\r
 \r
 const router = createRouter({\r
-  history: createWebHistory(import.meta.env.BASE_URL),\r
+  history: createWebHistory(""),\r
   routes: [\r
     {\r
       path: "/",\r
index 692075a3a55be371aee70b3487c4e375bc29625f..62faf14bafdf9b4be188ec96d804004c98b6f3a8 100644 (file)
@@ -4,13 +4,13 @@
   "start_url": "/",
   "icons": [
     {
-      "src": "/android-chrome-192x192.png",
+      "src": "android-chrome-192x192.png",
       "sizes": "192x192",
       "type": "image/png",
       "purpose": "any maskable"
     },
     {
-      "src": "/android-chrome-512x512.png",
+      "src": "android-chrome-512x512.png",
       "sizes": "512x512",
       "type": "image/png",
       "purpose": "any maskable"
index 713c238d5de5df345b503b6b34b2d6cfc8c3a215..787685758f910db7b0dd27b395bca31e04300508 100644 (file)
@@ -8,7 +8,7 @@
         font-style: normal;\r
         font-weight: 400;\r
         font-display: swap;\r
-        src: url("/assets/fonts/Poppins/Poppins-Regular.ttf");\r
+        src: url("assets/fonts/Poppins/Poppins-Regular.ttf");\r
     }\r
 \r
     body {\r
index 2c4dc131e23830c49cb3200711b93b4f90f767db..959af4d069b1b231b962451fa9592aa1dcaeec74 100644 (file)
@@ -1,7 +1,7 @@
 const tokenStorageKey = "token";
 
 function getCookieString(token) {
-  return `${tokenStorageKey}=${token}; path=/attachments; SameSite=Strict`;
+  return `${tokenStorageKey}=${token}; SameSite=Strict`;
 }
 
 export function storeToken(token, persist = false) {
index e168c1fd96bae390003621ece6494c3a834f9d19..9a5f35f8ec1ff2337df3755d2b361f1d8df65a4f 100644 (file)
@@ -55,4 +55,4 @@ class FileSystemAttachments(BaseAttachments):
 \r
     def _url_for_filename(self, filename: str) -> str:\r
         """Return the URL for the given filename."""\r
-        return f"/attachments/{urllib.parse.quote(filename)}"\r
+        return f"attachments/{urllib.parse.quote(filename)}"\r
index a620d48ed36e25aa68097433bc22c7236e008576..4f4f9ee0c23bad11c99bd62def5026d6021158e7 100644 (file)
@@ -10,6 +10,7 @@ class GlobalConfig:
         logger.debug("Loading global config...")\r
         self.auth_type: AuthType = self._load_auth_type()\r
         self.hide_recently_modified: bool = self._load_hide_recently_modified()\r
+        self.path_prefix: str = self._load_path_prefix()\r
 \r
     def load_auth(self):\r
         if self.auth_type in (AuthType.NONE, AuthType.READ_ONLY):\r
@@ -50,6 +51,14 @@ class GlobalConfig:
         key = "FLATNOTES_HIDE_RECENTLY_MODIFIED"\r
         return get_env(key, mandatory=False, default=False, cast_bool=True)\r
 \r
+    def _load_path_prefix(self):\r
+        key = "FLATNOTES_PATH_PREFIX"\r
+        value = get_env(key, mandatory=False, default="")\r
+        value = value.rstrip("/")\r
+        if value and not value.startswith("/"):\r
+            value = "/" + value\r
+        return value\r
+\r
 \r
 class AuthType(str, Enum):\r
     NONE = "none"\r
index 977ef828fb6a8494dc02bf2e4c5f881db5e20aec..367830b5d654207f5e32b5c209b682c6deaa559f 100644 (file)
@@ -1,4 +1,5 @@
 import os\r
+import re\r
 import sys\r
 \r
 from pydantic import BaseModel\r
@@ -59,6 +60,22 @@ def get_env(
     return value\r
 \r
 \r
+def replace_base_href(html_file, path_prefix):\r
+    """Replace the href value for the base element in an HTML file."""\r
+    base_path = path_prefix + "/"\r
+    logger.info(\r
+        f"Replacing href value for base element in '{html_file}' "\r
+        + f"with '{base_path}'."\r
+    )\r
+    with open(html_file, "r", encoding="utf-8") as f:\r
+        html = f.read()\r
+    pattern = r'(<base\s+href=")[^"]*(")'\r
+    replacement = r"\1" + base_path + r"\2"\r
+    updated_html = re.sub(pattern, replacement, html, flags=re.IGNORECASE)\r
+    with open(html_file, "w", encoding="utf-8") as f:\r
+        f.write(updated_html)\r
+\r
+\r
 class CustomBaseModel(BaseModel):\r
     class Config:\r
         alias_generator = camel_case\r
index cc271879622bad4bda443152524de43e52c219cd..aa3ef92c86cd03e2d054cb1e510915fd1c2ce103 100644 (file)
@@ -1,6 +1,6 @@
 from typing import List, Literal\r
 \r
-from fastapi import Depends, FastAPI, HTTPException, UploadFile\r
+from fastapi import APIRouter, Depends, FastAPI, HTTPException, UploadFile\r
 from fastapi.responses import HTMLResponse\r
 from fastapi.staticfiles import StaticFiles\r
 \r
@@ -10,6 +10,7 @@ from attachments.models import AttachmentCreateResponse
 from auth.base import BaseAuth\r
 from auth.models import Login, Token\r
 from global_config import AuthType, GlobalConfig, GlobalConfigResponseModel\r
+from helpers import replace_base_href\r
 from notes.base import BaseNotes\r
 from notes.models import Note, NoteCreate, NoteUpdate, SearchResult\r
 \r
@@ -18,15 +19,20 @@ auth: BaseAuth = global_config.load_auth()
 note_storage: BaseNotes = global_config.load_note_storage()\r
 attachment_storage: BaseAttachments = global_config.load_attachment_storage()\r
 auth_deps = [Depends(auth.authenticate)] if auth else []\r
-app = FastAPI()\r
+router = APIRouter()\r
+app = FastAPI(\r
+    docs_url=global_config.path_prefix + "/docs",\r
+    openapi_url=global_config.path_prefix + "/openapi.json",\r
+)\r
+replace_base_href("client/dist/index.html", global_config.path_prefix)\r
 \r
 \r
 # region UI\r
-@app.get("/", include_in_schema=False)\r
-@app.get("/login", include_in_schema=False)\r
-@app.get("/search", include_in_schema=False)\r
-@app.get("/new", include_in_schema=False)\r
-@app.get("/note/{title}", include_in_schema=False)\r
+@router.get("/", include_in_schema=False)\r
+@router.get("/login", include_in_schema=False)\r
+@router.get("/search", include_in_schema=False)\r
+@router.get("/new", include_in_schema=False)\r
+@router.get("/note/{title}", include_in_schema=False)\r
 def root(title: str = ""):\r
     with open("client/dist/index.html", "r", encoding="utf-8") as f:\r
         html = f.read()\r
@@ -39,7 +45,7 @@ def root(title: str = ""):
 # region Login\r
 if global_config.auth_type not in [AuthType.NONE, AuthType.READ_ONLY]:\r
 \r
-    @app.post("/api/token", response_model=Token)\r
+    @router.post("/api/token", response_model=Token)\r
     def token(data: Login):\r
         try:\r
             return auth.login(data)\r
@@ -54,7 +60,7 @@ if global_config.auth_type not in [AuthType.NONE, AuthType.READ_ONLY]:
 \r
 # region Notes\r
 # Get Note\r
-@app.get(\r
+@router.get(\r
     "/api/notes/{title}",\r
     dependencies=auth_deps,\r
     response_model=Note,\r
@@ -74,7 +80,7 @@ def get_note(title: str):
 if global_config.auth_type != AuthType.READ_ONLY:\r
 \r
     # Create Note\r
-    @app.post(\r
+    @router.post(\r
         "/api/notes",\r
         dependencies=auth_deps,\r
         response_model=Note,\r
@@ -94,7 +100,7 @@ if global_config.auth_type != AuthType.READ_ONLY:
             )\r
 \r
     # Update Note\r
-    @app.patch(\r
+    @router.patch(\r
         "/api/notes/{title}",\r
         dependencies=auth_deps,\r
         response_model=Note,\r
@@ -115,7 +121,7 @@ if global_config.auth_type != AuthType.READ_ONLY:
             raise HTTPException(404, api_messages.note_not_found)\r
 \r
     # Delete Note\r
-    @app.delete(\r
+    @router.delete(\r
         "/api/notes/{title}",\r
         dependencies=auth_deps,\r
         response_model=None,\r
@@ -136,7 +142,7 @@ if global_config.auth_type != AuthType.READ_ONLY:
 \r
 \r
 # region Search\r
-@app.get(\r
+@router.get(\r
     "/api/search",\r
     dependencies=auth_deps,\r
     response_model=List[SearchResult],\r
@@ -153,7 +159,7 @@ def search(
     return note_storage.search(term, sort=sort, order=order, limit=limit)\r
 \r
 \r
-@app.get(\r
+@router.get(\r
     "/api/tags",\r
     dependencies=auth_deps,\r
     response_model=List[str],\r
@@ -167,7 +173,7 @@ def get_tags():
 \r
 \r
 # region Config\r
-@app.get("/api/config", response_model=GlobalConfigResponseModel)\r
+@router.get("/api/config", response_model=GlobalConfigResponseModel)\r
 def get_config():\r
     """Retrieve server-side config required for the UI."""\r
     return GlobalConfigResponseModel(\r
@@ -181,13 +187,13 @@ def get_config():
 \r
 # region Attachments\r
 # Get Attachment\r
-@app.get(\r
+@router.get(\r
     "/api/attachments/{filename}",\r
     dependencies=auth_deps,\r
 )\r
 # Include a secondary route used to create relative URLs that can be used\r
 # outside the context of flatnotes (e.g. "/attachments/image.jpg").\r
-@app.get(\r
+@router.get(\r
     "/attachments/{filename}",\r
     dependencies=auth_deps,\r
     include_in_schema=False,\r
@@ -210,7 +216,7 @@ def get_attachment(filename: str):
 if global_config.auth_type != AuthType.READ_ONLY:\r
 \r
     # Create Attachment\r
-    @app.post(\r
+    @router.post(\r
         "/api/attachments",\r
         dependencies=auth_deps,\r
         response_model=AttachmentCreateResponse,\r
@@ -232,7 +238,7 @@ if global_config.auth_type != AuthType.READ_ONLY:
 \r
 \r
 # region Healthcheck\r
-@app.get("/health")\r
+@router.get("/health")\r
 def healthcheck() -> str:\r
     """A lightweight endpoint that simply returns 'OK' to indicate the server\r
     is running."""\r
@@ -241,4 +247,9 @@ def healthcheck() -> str:
 \r
 # endregion\r
 \r
-app.mount("/", StaticFiles(directory="client/dist"), name="dist")\r
+app.include_router(router, prefix=global_config.path_prefix)\r
+app.mount(\r
+    global_config.path_prefix,\r
+    StaticFiles(directory="client/dist"),\r
+    name="dist",\r
+)\r
index 34a0c6561c191142cbf2c70735e0ec8dd1c4087b..3faaad318d3e0a914f08a7dbb32e9a9cded8ab5a 100644 (file)
@@ -6,7 +6,9 @@ const devApiUrl = "http://127.0.0.1:8000";
 export default defineConfig({\r
   plugins: [vue()],\r
   root: "client",\r
+  base: "",\r
   server: {\r
+    // Note: The FLATNOTES_PATH_PREFIX environment variable is not supported by the dev server\r
     port: 8080,\r
     proxy: {\r
       "/api/": {\r
git clone https://git.99rst.org/PROJECT