Skip to content

add sharePath url option setting #1288

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 66 additions & 13 deletions src/public/app/widgets/type_widgets/options/other/share_settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,51 +14,94 @@ const TPL = /*html*/`
</label>
<p class="form-text">${t("share.redirect_bare_domain_description")}</p>

<div class="share-root-check mt-2 mb-2" style="display: none;">
<button class="btn btn-sm btn-secondary check-share-root">${t("share.check_share_root")}</button>
<div class="share-root-status form-text mt-2"></div>
</div>

<label class="tn-checkbox">
<input class="form-check-input use-clean-urls" type="checkbox" name="useCleanUrls" value="true">
${t("share.use_clean_urls")}
</label>
<p class="form-text">${t("share.use_clean_urls_description")}</p>

<div class="form-group">
<label>${t("share.share_path")}</label>
<div>
<input type="text" class="form-control share-path" placeholder="${t("share.share_path_placeholder")}">
</div>
<div class="form-text">
${t("share.share_path_description")}
</div>
</div>

<label class="tn-checkbox">
<input class="form-check-input" type="checkbox" name="showLoginInShareTheme" value="true">
<input class="form-check-input show-login-in-share-theme" type="checkbox" name="showLoginInShareTheme" value="true">
${t("share.show_login_link")}
</label>
<p class="form-text">${t("share.show_login_link_description")}</p>
</div>`;

export default class ShareSettingsOptions extends OptionsWidget {
private $redirectBareDomain!: JQuery<HTMLInputElement>;
private $showLoginInShareTheme!: JQuery<HTMLInputElement>;
private $useCleanUrls!: JQuery<HTMLInputElement>;
private $sharePath!: JQuery<HTMLInputElement>;
private $shareRootCheck!: JQuery<HTMLElement>;
private $shareRootStatus!: JQuery<HTMLElement>;

doRender() {
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() {
Expand Down Expand Up @@ -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);
}
}
18 changes: 13 additions & 5 deletions src/public/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1270,8 +1270,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",
Expand Down Expand Up @@ -1719,7 +1720,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.",
Expand Down
3 changes: 3 additions & 0 deletions src/routes/api/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"allowedHtmlTags",
"redirectBareDomain",
"showLoginInShareTheme",
"shareSubtree",
"useCleanUrls",
"sharePath",
"splitEditorOrientation",
"mfaEnabled",
"mfaMethod"
Expand Down
4 changes: 2 additions & 2 deletions src/routes/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ function setPassword(req: Request, res: Response) {
if (error) {
res.render("set_password", {
error,
assetPath,
appPath
assetPath: assetPath,
appPath: appPath
});
return;
}
Expand Down
2 changes: 2 additions & 0 deletions src/routes/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
74 changes: 68 additions & 6 deletions src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -155,6 +216,7 @@ function checkCredentials(req: Request, res: Response, next: NextFunction) {

export default {
checkAuth,
checkCleanUrl,
checkApiAuth,
checkAppInitialized,
checkPasswordSet,
Expand Down
13 changes: 12 additions & 1 deletion src/services/options_init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
];

/**
Expand Down
3 changes: 3 additions & 0 deletions src/services/options_interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
// Share settings
redirectBareDomain: boolean;
showLoginInShareTheme: boolean;
shareSubtree: boolean;
useCleanUrls: boolean;
sharePath: string;
}

export type OptionNames = keyof OptionDefinitions;
Expand Down
Loading