diff --git a/src/public/app/widgets/type_widgets/options/other/share_settings.ts b/src/public/app/widgets/type_widgets/options/other/share_settings.ts
index 1b1eba047..c13dee911 100644
--- a/src/public/app/widgets/type_widgets/options/other/share_settings.ts
+++ b/src/public/app/widgets/type_widgets/options/other/share_settings.ts
@@ -14,14 +14,39 @@ const TPL = /*html*/`
${t("share.redirect_bare_domain_description")}
+
+
+
+
+
+
+ ${t("share.use_clean_urls_description")}
+
+
+
${t("share.show_login_link_description")}
`;
export default class ShareSettingsOptions extends OptionsWidget {
+ private $redirectBareDomain!: JQuery;
+ private $showLoginInShareTheme!: JQuery;
+ private $useCleanUrls!: JQuery;
+ private $sharePath!: JQuery;
private $shareRootCheck!: JQuery;
private $shareRootStatus!: JQuery;
@@ -29,36 +54,54 @@ export default class ShareSettingsOptions extends OptionsWidget {
this.$widget = $(TPL);
this.contentSized();
+ this.$redirectBareDomain = this.$widget.find(".redirect-bare-domain");
+ this.$showLoginInShareTheme = this.$widget.find(".show-login-in-share-theme");
+ this.$useCleanUrls = this.$widget.find(".use-clean-urls");
+ this.$sharePath = this.$widget.find(".share-path");
this.$shareRootCheck = this.$widget.find(".share-root-check");
this.$shareRootStatus = this.$widget.find(".share-root-status");
- // Add change handlers for both checkboxes
- this.$widget.find('input[type="checkbox"]').on("change", (e: JQuery.ChangeEvent) => {
+ this.$redirectBareDomain.on('change', () => {
+ const redirectBareDomain = this.$redirectBareDomain.is(":checked");
this.save();
// Show/hide share root status section based on redirectBareDomain checkbox
- const target = e.target as HTMLInputElement;
- if (target.name === "redirectBareDomain") {
- this.$shareRootCheck.toggle(target.checked);
- if (target.checked) {
- this.checkShareRoot();
- }
+ this.$shareRootCheck.toggle(redirectBareDomain);
+ if (redirectBareDomain) {
+ this.checkShareRoot();
}
});
+ this.$showLoginInShareTheme.on('change', () => {
+ const showLoginInShareTheme = this.$showLoginInShareTheme.is(":checked");
+ this.save();
+ });
+
+ this.$useCleanUrls.on('change', () => {
+ const useCleanUrls = this.$useCleanUrls.is(":checked");
+ this.save();
+ });
+
+ this.$sharePath.on('change', () => {
+ const sharePath = this.$sharePath.val() as string;
+ this.save();
+ });
+
// Add click handler for check share root button
this.$widget.find(".check-share-root").on("click", () => this.checkShareRoot());
}
async optionsLoaded(options: OptionMap) {
const redirectBareDomain = options.redirectBareDomain === "true";
- this.$widget.find('input[name="redirectBareDomain"]').prop("checked", redirectBareDomain);
+ this.$redirectBareDomain.prop("checked", redirectBareDomain);
this.$shareRootCheck.toggle(redirectBareDomain);
if (redirectBareDomain) {
await this.checkShareRoot();
}
- this.$widget.find('input[name="showLoginInShareTheme"]').prop("checked", options.showLoginInShareTheme === "true");
+ this.$showLoginInShareTheme.prop("checked", options.showLoginInShareTheme === "true");
+ this.$useCleanUrls.prop("checked", options.useCleanUrls === "true");
+ this.$sharePath.val(options.sharePath);
}
async checkShareRoot() {
@@ -86,10 +129,20 @@ export default class ShareSettingsOptions extends OptionsWidget {
}
async save() {
- const redirectBareDomain = this.$widget.find('input[name="redirectBareDomain"]').prop("checked");
+ const redirectBareDomain = this.$redirectBareDomain.is(":checked");
await this.updateOption<"redirectBareDomain">("redirectBareDomain", redirectBareDomain.toString());
- const showLoginInShareTheme = this.$widget.find('input[name="showLoginInShareTheme"]').prop("checked");
+ const showLoginInShareTheme = this.$showLoginInShareTheme.is(":checked");
await this.updateOption<"showLoginInShareTheme">("showLoginInShareTheme", showLoginInShareTheme.toString());
+
+ const useCleanUrls = this.$useCleanUrls.is(":checked");
+ await this.updateOption<"useCleanUrls">("useCleanUrls", useCleanUrls.toString());
+
+ // Ensure sharePath always starts with a slash
+ let sharePath = this.$sharePath.val() as string;
+ if (sharePath && !sharePath.startsWith('/')) {
+ sharePath = '/' + sharePath;
+ }
+ await this.updateOption<"sharePath">("sharePath", sharePath);
}
}
diff --git a/src/public/translations/en/translation.json b/src/public/translations/en/translation.json
index 45c934f5e..50ace1d9b 100644
--- a/src/public/translations/en/translation.json
+++ b/src/public/translations/en/translation.json
@@ -272,12 +272,12 @@
"help_title": "Help on Note Revisions",
"close": "Close",
"revision_last_edited": "This revision was last edited on {{date}}",
- "confirm_delete_all": "Do you want to delete all revisions of this note?",
+ "confirm_delete_all": "Do you want to delete all revisions of this note? This action will erase the revision title and content, but still preserve the revision metadata.",
"no_revisions": "No revisions for this note yet...",
"restore_button": "Restore this revision",
"confirm_restore": "Do you want to restore this revision? This will overwrite the current title and content of the note with this revision.",
"delete_button": "Delete this revision",
- "confirm_delete": "Do you want to delete this revision?",
+ "confirm_delete": "Do you want to delete this revision? This action will delete the revision title and content, but still preserve the revision metadata.",
"revisions_deleted": "Note revisions have been deleted.",
"revision_restored": "Note revision has been restored.",
"revision_deleted": "Note revision has been deleted.",
@@ -376,7 +376,7 @@
"auto_read_only_disabled": "text/code notes can be set automatically into read mode when they are too large. You can disable this behavior on per-note basis by adding this label to the note",
"app_css": "marks CSS notes which are loaded into the Trilium application and can thus be used to modify Trilium's looks.",
"app_theme": "marks CSS notes which are full Trilium themes and are thus available in Trilium options.",
- "app_theme_base": "set to \"next\", \"next-light\", or \"next-dark\" to use the corresponding TriliumNext theme (auto, light or dark) as the base for a custom theme, instead of the legacy one.",
+ "app_theme_base": "set to \"next\" in order to use the TriliumNext theme as a base for a custom theme instead of the legacy one.",
"css_class": "value of this label is then added as CSS class to the node representing given note in the tree. This can be useful for advanced theming. Can be used in template notes.",
"icon_class": "value of this label is added as a CSS class to the icon on the tree which can help visually distinguish the notes in the tree. Example might be bx bx-home - icons are taken from boxicons. Can be used in template notes.",
"page_size": "number of items per page in note listing",
@@ -744,8 +744,7 @@
"basic_properties": {
"note_type": "Note type",
"editable": "Editable",
- "basic_properties": "Basic Properties",
- "language": "Language"
+ "basic_properties": "Basic Properties"
},
"book_properties": {
"view_type": "View type",
@@ -1270,8 +1269,9 @@
"etapi": {
"title": "ETAPI",
"description": "ETAPI is a REST API used to access Trilium instance programmatically, without UI.",
- "see_more": "See more details in the {{- link_to_wiki}} and the {{- link_to_openapi_spec}} or the {{- link_to_swagger_ui }}.",
+ "see_more": "See more details on",
"wiki": "wiki",
+ "and": "and",
"openapi_spec": "ETAPI OpenAPI spec",
"swagger_ui": "ETAPI Swagger UI",
"create_token": "Create new ETAPI token",
@@ -1719,7 +1719,14 @@
"check_share_root": "Check Share Root Status",
"share_root_found": "Share root note '{{noteTitle}}' is ready",
"share_root_not_found": "No note with #shareRoot label found",
- "share_root_not_shared": "Note '{{noteTitle}}' has #shareRoot label but is not shared"
+ "share_root_not_shared": "Note '{{noteTitle}}' has #shareRoot label but is not Shared",
+ "use_clean_urls": "Use clean URLs for shared notes",
+ "use_clean_urls_description": "When enabled, shared note URLs will be simplified from /share/STi3RCMhUvG6 to /STi3RCMhUvG6",
+ "share_path": "Share path",
+ "share_path_description": "The url prefix for shared notes (e.g. '/share' --> '/share/noteId' and '/' --> '/noteId')",
+ "share_path_placeholder": "/share or / for root",
+ "share_subtree": "Share subtree",
+ "share_subtree_description": "Share the entire subtree, not just the note"
},
"time_selector": {
"invalid_input": "The entered time value is not a valid number.",
diff --git a/src/routes/api/options.ts b/src/routes/api/options.ts
index aeb4e9009..6bb1c0d9f 100644
--- a/src/routes/api/options.ts
+++ b/src/routes/api/options.ts
@@ -82,6 +82,9 @@ const ALLOWED_OPTIONS = new Set([
"allowedHtmlTags",
"redirectBareDomain",
"showLoginInShareTheme",
+ "shareSubtree",
+ "useCleanUrls",
+ "sharePath",
"splitEditorOrientation",
"mfaEnabled",
"mfaMethod"
diff --git a/src/routes/login.ts b/src/routes/login.ts
index 3f4d52f32..b22964816 100644
--- a/src/routes/login.ts
+++ b/src/routes/login.ts
@@ -51,8 +51,8 @@ function setPassword(req: Request, res: Response) {
if (error) {
res.render("set_password", {
error,
- assetPath,
- appPath
+ assetPath: assetPath,
+ appPath: appPath
});
return;
}
diff --git a/src/routes/routes.ts b/src/routes/routes.ts
index cd49f5a6e..80d464dc2 100644
--- a/src/routes/routes.ts
+++ b/src/routes/routes.ts
@@ -100,6 +100,8 @@ const uploadMiddlewareWithErrorHandling = function (req: express.Request, res: e
};
function register(app: express.Application) {
+ app.use(auth.checkCleanUrl);
+
route(GET, "/", [auth.checkAuth, csrfMiddleware], indexRoute.index);
route(GET, "/login", [auth.checkAppInitialized, auth.checkPasswordSet], loginRoute.loginPage);
route(GET, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPasswordPage);
diff --git a/src/services/auth.ts b/src/services/auth.ts
index d1d951fc0..26b015798 100644
--- a/src/services/auth.ts
+++ b/src/services/auth.ts
@@ -48,28 +48,77 @@ function checkAuth(req: Request, res: Response, next: NextFunction) {
// Check if any note has the #shareRoot label
const shareRootNotes = attributes.getNotesWithLabel("shareRoot");
if (shareRootNotes.length === 0) {
+ // should this be a translation string?
res.status(404).json({ message: "Share root not found. Please set up a note with #shareRoot label first." });
return;
}
+
+ // Get the configured share path
+ const sharePath = options.getOption("sharePath") || '/share';
+
+ // Check if we're already at the share path to prevent redirect loops
+ if (req.path === sharePath || req.path.startsWith(`${sharePath}/`)) {
+ log.info(`checkAuth: Already at share path, skipping redirect. Path: ${req.path}, SharePath: ${sharePath}`);
+ next();
+ return;
+ }
+
+ // Redirect to the share path
+ log.info(`checkAuth: Redirecting to share path. From: ${req.path}, To: ${sharePath}`);
+ res.redirect(`${sharePath}/`);
+ } else {
+ res.redirect("login");
}
- res.redirect(hasRedirectBareDomain ? "share" : "login");
} else {
next();
}
}
+/**
+ * Checks if a URL path might be a shared note ID when clean URLs are enabled
+ */
+function checkCleanUrl(req: Request, res: Response, next: NextFunction) {
+ // Only process if not logged in and clean URLs are enabled
+ if (!req.session.loggedIn && !isElectron && !noAuthentication &&
+ options.getOptionBool("redirectBareDomain") &&
+ options.getOptionBool("useCleanUrls")) {
+
+ // Get the configured share path
+ const sharePath = options.getOption("sharePath") || '/share';
+
+ // Get path without leading slash
+ const path = req.path.substring(1);
+
+ // Skip processing for known routes, empty paths, and paths that already start with sharePath
+ if (!path ||
+ path === 'login' ||
+ path === 'setup' ||
+ path.startsWith('api/') ||
+ req.path === sharePath ||
+ req.path.startsWith(`${sharePath}/`)) {
+ log.info(`checkCleanUrl: Skipping redirect. Path: ${req.path}, SharePath: ${sharePath}`);
+ next();
+ return;
+ }
+ // If sharePath is just '/', we don't need to redirect
+ if (sharePath === '/') {
+ log.info(`checkCleanUrl: SharePath is root, skipping redirect. Path: ${req.path}`);
+ next();
+ return;
+ }
-// for electron things which need network stuff
-// currently, we're doing that for file upload because handling form data seems to be difficult
-function checkApiAuthOrElectron(req: Request, res: Response, next: NextFunction) {
- if (!req.session.loggedIn && !isElectron && !noAuthentication) {
- reject(req, res, "Logged in session not found");
+ // Redirect to the share URL with this ID
+ log.info(`checkCleanUrl: Redirecting to share path. From: ${req.path}, To: ${sharePath}/${path}`);
+ res.redirect(`${sharePath}/${path}`);
} else {
next();
}
}
+/**
+ * Middleware for API authentication - works for both sync and normal API
+ */
function checkApiAuth(req: Request, res: Response, next: NextFunction) {
if (!req.session.loggedIn && !noAuthentication) {
reject(req, res, "Logged in session not found");
@@ -78,6 +127,18 @@ function checkApiAuth(req: Request, res: Response, next: NextFunction) {
}
}
+
+
+// for electron things which need network stuff
+// currently, we're doing that for file upload because handling form data seems to be difficult
+function checkApiAuthOrElectron(req: Request, res: Response, next: NextFunction) {
+ if (!req.session.loggedIn && !isElectron && !noAuthentication) {
+ reject(req, res, "Logged in session not found");
+ } else {
+ next();
+ }
+}
+
function checkAppInitialized(req: Request, res: Response, next: NextFunction) {
if (!sqlInit.isDbInitialized()) {
res.redirect("setup");
@@ -155,6 +216,7 @@ function checkCredentials(req: Request, res: Response, next: NextFunction) {
export default {
checkAuth,
+ checkCleanUrl,
checkApiAuth,
checkAppInitialized,
checkPasswordSet,
diff --git a/src/services/options_init.ts b/src/services/options_init.ts
index 52417bbb2..fcd0de3bc 100644
--- a/src/services/options_init.ts
+++ b/src/services/options_init.ts
@@ -175,8 +175,19 @@ const defaultOptions: DefaultOption[] = [
},
// Share settings
+ {
+ name: "sharePath",
+ // ensure always starts with slash
+ value: (optionsMap) => {
+ const sharePath = optionsMap.sharePath || "/share";
+ return sharePath.startsWith("/") ? sharePath : "/" + sharePath;
+ },
+ isSynced: true
+ },
{ name: "redirectBareDomain", value: "false", isSynced: true },
- { name: "showLoginInShareTheme", value: "false", isSynced: true }
+ { name: "showLoginInShareTheme", value: "false", isSynced: true },
+ { name: "useCleanUrls", value: "false", isSynced: true },
+ { name: "shareSubtree", value: "false", isSynced: true }
];
/**
diff --git a/src/services/options_interface.ts b/src/services/options_interface.ts
index 07b89ebe2..e74d4d7d8 100644
--- a/src/services/options_interface.ts
+++ b/src/services/options_interface.ts
@@ -120,6 +120,9 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions {
- if (req.path.substr(-1) !== "/") {
- res.redirect("../share/");
- return;
- }
+ const sharePath = options.getOption("sharePath") || '/share';
- shacaLoader.ensureLoad();
+ // Handle root path specially
+ if (sharePath === '/') {
+ router.get('/', (req, res, next) => {
+ shacaLoader.ensureLoad();
- if (!shaca.shareRootNote) {
- res.status(404).json({ message: "Share root note not found" });
- return;
- }
+ if (!shaca.shareRootNote) {
+ res.status(404).json({ message: "Share root not found" });
+ return;
+ }
- renderNote(shaca.shareRootNote, req, res);
- });
+ renderNote(shaca.shareRootNote, req, res);
+ });
+ } else {
+ router.get(`${sharePath}`, (req, res, next) => {
+ // Redirect to the path with trailing slash for consistency
+ res.redirect(`${sharePath}/`);
+ });
+
+ router.get(`${sharePath}/`, (req, res, next) => {
+ if (req.path !== `${sharePath}/`) {
+ res.redirect(`${sharePath}/`);
+ return;
+ }
+
+ shacaLoader.ensureLoad();
+
+ if (!shaca.shareRootNote) {
+ res.status(404).json({ message: "Share root not found" });
+ return;
+ }
+
+ renderNote(shaca.shareRootNote, req, res);
+ });
+ }
+
+ if (sharePath === '/' && options.getOptionBool("useCleanUrls") && options.getOptionBool("redirectBareDomain")) {
+ router.get("/:shareId", (req, res, next) => {
+ shacaLoader.ensureLoad();
+
+ const { shareId } = req.params;
+
+ // Skip processing for known routes
+ if (shareId === 'login' || shareId === 'setup' || shareId.startsWith('api/')) {
+ next();
+ return;
+ }
+
+ const note = shaca.aliasToNote[shareId] || shaca.notes[shareId];
+
+ renderNote(note, req, res);
+ });
+ }
- router.get("/share/:shareId", (req, res) => {
+ router.get(`${sharePath}/:shareId`, (req, res, next) => {
shacaLoader.ensureLoad();
const { shareId } = req.params;
@@ -229,7 +268,7 @@ function register(router: Router) {
renderNote(note, req, res);
});
- router.get("/share/api/notes/:noteId", (req, res) => {
+ router.get(`${sharePath}/api/notes/:noteId`, (req, res, next) => {
shacaLoader.ensureLoad();
let note: SNote | boolean;
@@ -242,7 +281,7 @@ function register(router: Router) {
res.json(note.getPojo());
});
- router.get("/share/api/notes/:noteId/download", (req, res) => {
+ router.get(`${sharePath}/api/notes/:noteId/download`, (req, res, next) => {
shacaLoader.ensureLoad();
let note: SNote | boolean;
@@ -264,7 +303,7 @@ function register(router: Router) {
});
// :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename
- router.get("/share/api/images/:noteId/:filename", (req, res) => {
+ router.get(`${sharePath}/api/images/:noteId/:filename`, (req, res, next) => {
shacaLoader.ensureLoad();
let image: SNote | boolean;
@@ -290,7 +329,7 @@ function register(router: Router) {
});
// :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename
- router.get("/share/api/attachments/:attachmentId/image/:filename", (req, res) => {
+ router.get(`${sharePath}/api/attachments/:attachmentId/image/:filename`, (req, res, next) => {
shacaLoader.ensureLoad();
let attachment: SAttachment | boolean;
@@ -308,7 +347,7 @@ function register(router: Router) {
}
});
- router.get("/share/api/attachments/:attachmentId/download", (req, res) => {
+ router.get(`${sharePath}/api/attachments/:attachmentId/download`, (req, res, next) => {
shacaLoader.ensureLoad();
let attachment: SAttachment | boolean;
@@ -330,7 +369,7 @@ function register(router: Router) {
});
// used for PDF viewing
- router.get("/share/api/notes/:noteId/view", (req, res) => {
+ router.get(`${sharePath}/api/notes/:noteId/view`, (req, res, next) => {
shacaLoader.ensureLoad();
let note: SNote | boolean;
@@ -348,7 +387,7 @@ function register(router: Router) {
});
// Used for searching, require noteId so we know the subTreeRoot
- router.get("/share/api/notes", (req, res) => {
+ router.get(`${sharePath}/api/notes`, (req, res, next) => {
shacaLoader.ensureLoad();
const ancestorNoteId = req.query.ancestorNoteId ?? "_share";