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}`;
}
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);
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,
});
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,
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,
});
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);
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,
});
export async function deleteNote(title) {
try {
- await api.delete(`/api/notes/${title}`);
+ await api.delete(`api/notes/${title}`);
} catch (response) {
return Promise.reject(response);
}
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);
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",
},
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
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
"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"
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
const tokenStorageKey = "token";
function getCookieString(token) {
- return `${tokenStorageKey}=${token}; path=/attachments; SameSite=Strict`;
+ return `${tokenStorageKey}=${token}; SameSite=Strict`;
}
export function storeToken(token, persist = false) {
\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
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
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
import os\r
+import re\r
import sys\r
\r
from pydantic import BaseModel\r
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
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
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
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
# 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
\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
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
)\r
\r
# Update Note\r
- @app.patch(\r
+ @router.patch(\r
"/api/notes/{title}",\r
dependencies=auth_deps,\r
response_model=Note,\r
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
\r
\r
# region Search\r
-@app.get(\r
+@router.get(\r
"/api/search",\r
dependencies=auth_deps,\r
response_model=List[SearchResult],\r
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
\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
\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
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
\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
\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
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