Skip to content

feat: custom sharePath setting #1781

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 33 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8487c8c
WIP: allow no share path url prefix at all (not working yet)
maphew Feb 25, 2025
6725f81
WIP: options page works, but routing broken when logged out
maphew Feb 26, 2025
bba2f6d
wip: another attempt (and use translation syntax this time)
maphew Mar 8, 2025
c9d1512
conflicts fixed - Merge remote-tracking branch 'origin/develop'
maphew Mar 8, 2025
fbe7d64
Merge branch 'develop' into feat/clean-share-url
JYC333 Apr 4, 2025
1aecf66
Merge branch 'develop' into feat/clean-share-url
JYC333 Apr 10, 2025
81d2fbc
fix: 🐛 add back missing translation
JYC333 Apr 11, 2025
37f3a9b
Merge branch 'develop' into feat/clean-share-url
pano9000 Apr 17, 2025
0be508e
fix(share/routes): fix crash on clean DB startup when sharePath optio…
pano9000 Apr 17, 2025
d72a0d3
fix(services/auth): fix crash on clean DB startup when options are no…
pano9000 Apr 17, 2025
9a11fc1
fix(share_settings): fix missing class in redirect-bare-domain input
pano9000 Apr 17, 2025
df45fa2
fix(share/routes): fix redirect loop
pano9000 Apr 17, 2025
56fc2d9
fix(share_settings): fix not being able to set share path
pano9000 Apr 18, 2025
ab901a5
refactor(share_settings): get rid of save() method
pano9000 Apr 18, 2025
a8901e6
chore: revert back unnecessary changes from unclean merge
pano9000 Apr 18, 2025
dabdfad
fix(share/routes): remove unnecessary redirects that cause loops
pano9000 Apr 20, 2025
9f0a023
fix(share_settings): stop runnning checkShareRoot on init and on redi…
pano9000 Apr 20, 2025
b0030f8
feat(share_settings): group options that belong together logically
pano9000 Apr 20, 2025
0e31aab
refactor(share_settings): use this.$shareRootCheck instead of creatin…
pano9000 Apr 20, 2025
6dc687e
feat(share_settings): improve checkShareRoot
pano9000 Apr 21, 2025
c90364b
feat(share_settings): improve sharePath input handling
pano9000 Apr 21, 2025
d1d4b47
test(share_settings): add initial test for normalizeSharePathInput
pano9000 Apr 21, 2025
1b7266f
chore(share_settings): remove unnecessary comment
pano9000 Apr 21, 2025
00b5aef
feat(share_settings): add support for adding "/" as sharePath
pano9000 Apr 21, 2025
43166db
refactor: remove "cleanUrl" related code for now
pano9000 Apr 25, 2025
f4b5ed7
refactor(options_init): remove sharePath normalization
pano9000 Apr 25, 2025
0ae9a29
refactor: remove "cleanUrl" related code for now
pano9000 Apr 25, 2025
3b1d7d0
feat: improve example and wording for share_path_description
pano9000 Apr 25, 2025
128d890
chore(share_settings): add a TODO hint for currently active bug
pano9000 Apr 25, 2025
30a191c
fix(share_settings): disallow "/" as share root for now as it is not …
pano9000 Apr 25, 2025
34e7901
refactor: remove "cleanUrl" related string for now
pano9000 Apr 25, 2025
2bd8c21
Merge branch 'develop' into feat/clean-share-url
pano9000 May 12, 2025
3ae228f
Merge branch 'develop' into feat/clean-share-url
JYC333 Jun 9, 2025
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
10 changes: 9 additions & 1 deletion apps/client/src/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1874,9 +1874,17 @@
"show_login_link": "Show Login link in Share theme",
"show_login_link_description": "Add a login link to the Share page footer",
"check_share_root": "Check Share Root Status",
"check_share_root_error": "An unexpected error happened while checking the Share Root Status, please check the logs for more information.",
"share_note_title": "'{{noteTitle}}'",
"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",
"share_root_multiple_found": "Found multiple shared notes with a #shareRoot label: {{- foundNoteTitles}}. The note {{- activeNoteTitle}} will be used as shared root note.",
"share_path": "Share path",
"share_path_description": "The url prefix for shared notes (e.g. '/share' --> '/share/noteId' or '/custom-path' --> '/custom-path/noteId'). In order for the change to take effect, you need to restart the server.",
"share_path_placeholder": "/share or /custom-path",
"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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";

describe.skip("ShareSettingsOptions", () => {})
/*

Test currently fails during import:

FAIL app widgets/type_widgets/options/other/share_settings.spec.ts [ src/public/app/widgets/type_widgets/options/other/share_settings.spec.ts ]
TypeError: Class extends value undefined is not a constructor or null
❯ widgets/right_panel_widget.ts:20:32
20| class RightPanelWidget extends NoteContextAwareWidget {
21| private $bodyWrapper!: JQuery<HTMLElement>;
22| $body!: JQuery<HTMLElement>;


import ShareSettingsOptions from "./share_settings.js";

type TestCase<T extends (...args: any) => any> = [
desc: string,
fnParams: Parameters<T>,
expected: ReturnType<T>
];



describe("ShareSettingsOptions", () => {

describe("#normalizeSharePathInput", () => {

const testCases: TestCase<ShareSettingsOptions["normalizeSharePathInput"]>[] = [
[
"should handle multiple trailing '/' and remove them completely",
["/trailingtest////"],
"/trailingtest"
],
[
"should handle multiple starting '/' and replace them by a single '/'",
["////startingtest"],
"/startingtest"
],
[
"should handle multiple starting & trailing '/' and replace them by a single '/'",
["////startingAndTrailingTest///"],
"/startingAndTrailingTest"
],
[
"should not remove any '/' other than at the end or start of the input",
["/test/with/subpath"],
"/test/with/subpath"
],
[
"should prepend the string with a '/' if it does not start with one",
["testpath"],
"/testpath"
],
[
"should not change anything, if the string is a single '/'",
["/"],
"/"
],
];

testCases.forEach((testCase) => {
const [desc, fnParams, expected] = testCase;
return it(desc, () => {
const shareSettings = new ShareSettingsOptions();
const actual = shareSettings.normalizeSharePathInput(...fnParams);
expect(actual).toStrictEqual(expected);
});
});


})

})*/
184 changes: 134 additions & 50 deletions apps/client/src/widgets/type_widgets/options/other/share_settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,88 +8,172 @@ const TPL = /*html*/`
<div class="options-section">
<h4>${t("share.title")}</h4>

<label class="tn-checkbox">
<input class="form-check-input" type="checkbox" name="redirectBareDomain" value="true">
${t("share.redirect_bare_domain")}
</label>
<p class="form-text">${t("share.redirect_bare_domain_description")}</p>

<label class="tn-checkbox">
<input class="form-check-input" type="checkbox" name="showLoginInShareTheme" value="true">
${t("share.show_login_link")}
</label>
<p class="form-text">${t("share.show_login_link_description")}</p>
<div class="form-group">
<label class="tn-checkbox">
<input class="form-check-input redirect-bare-domain" type="checkbox" name="redirectBareDomain" value="true">
${t("share.redirect_bare_domain")}
</label>
<p class="form-text">${t("share.redirect_bare_domain_description")}</p>

<div class="share-root-check mt-2 mb-2">
<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>
</div>

<div class="form-group">
<label class="tn-checkbox">
<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>

<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>

</div>`;

export default class ShareSettingsOptions extends OptionsWidget {
private $redirectBareDomain!: JQuery<HTMLInputElement>;
private $showLoginInShareTheme!: 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.$sharePath = this.$widget.find(".share-path");
this.$shareRootCheck = this.$widget.find(".share-root-check");
this.$shareRootStatus = this.$widget.find(".share-root-status");
this.$shareRootCheck.hide();

// Add change handlers for both checkboxes
this.$widget.find('input[type="checkbox"]').on("change", (e: JQuery.ChangeEvent) => {
this.save();
this.$redirectBareDomain.on('change', async () => {
const redirectBareDomain = this.$redirectBareDomain.is(":checked");
await this.updateOption<"redirectBareDomain">("redirectBareDomain", redirectBareDomain.toString());

// 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);
});

this.$showLoginInShareTheme.on('change', async () => {
const showLoginInShareTheme = this.$showLoginInShareTheme.is(":checked");
await this.updateOption<"showLoginInShareTheme">("showLoginInShareTheme", showLoginInShareTheme.toString());
});

this.$sharePath.on('change', async () => {
const DEFAULT_SHAREPATH = "/share";
const sharePathInput = this.$sharePath.val()?.trim() || "";

// TODO: inform user if they try to add more than a single path prefix (i.e. /sharePath/test)
// → this currently is not properly working, as for some reason the assets URL is not correctly rewritten
// and it only includes the first path in the URL, e.g.
// http://localhost:8080/sharePath/assets/v0.93.0/node_modules/normalize.css/normalize.css
// instead of
// http://localhost:8080/sharePath/test/assets/v0.93.0/node_modules/normalize.css/normalize.css
// alternatively/better approach: fix this behaviour :-)
const normalizedSharePath = this.normalizeSharePathInput(sharePathInput);
const optionValue = (!sharePathInput || !normalizedSharePath || normalizedSharePath === "/")
? DEFAULT_SHAREPATH
: normalizedSharePath;

await this.updateOption<"sharePath">("sharePath", optionValue);
});

// Add click handler for check share root button
this.$widget.find(".check-share-root").on("click", () => this.checkShareRoot());
}

// Ensure sharePath always starts with a single slash and does not end with (one or multiple) trailing slashes
normalizeSharePathInput(sharePathInput: string) {

const REGEXP_STARTING_SLASH = /^\/+/g;
const REGEXP_TRAILING_SLASH = /\b\/+$/g;

const normalizedSharePath = (!sharePathInput.startsWith("/")
? `/${sharePathInput}`
: sharePathInput)
.replaceAll(REGEXP_TRAILING_SLASH, "")
.replaceAll(REGEXP_STARTING_SLASH, "/");

return normalizedSharePath;

}

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.$sharePath.val(options.sharePath);
}

async checkShareRoot() {
const $button = this.$widget.find(".check-share-root");
$button.prop("disabled", true);
this.$shareRootCheck.prop("disabled", true);

const setCheckShareRootStyle = (removeClassName: string, addClassName: string, text: string) => {
this.$shareRootStatus
.removeClass(removeClassName)
.addClass(addClassName)
.text(text);

this.$shareRootCheck.prop("disabled", false);
};

try {
const shareRootNotes = await searchService.searchForNotes("#shareRoot");
const sharedShareRootNote = shareRootNotes.find((note) => note.isShared());

if (sharedShareRootNote) {
this.$shareRootStatus
.removeClass("text-danger")
.addClass("text-success")
.text(t("share.share_root_found", { noteTitle: sharedShareRootNote.title }));
} else {
this.$shareRootStatus
.removeClass("text-success")
.addClass("text-danger")
.text(shareRootNotes.length > 0 ? t("share.share_root_not_shared", { noteTitle: shareRootNotes[0].title }) : t("share.share_root_not_found"));
const sharedShareRootNotes = shareRootNotes.filter((note) => note.isShared());

// No Note found that has the sharedRoot label AND is currently shared
if (sharedShareRootNotes.length < 1) {
const textMessage = (shareRootNotes.length > 0)
? t("share.share_root_not_shared", { noteTitle: shareRootNotes[0].title })
: t("share.share_root_not_found");

return setCheckShareRootStyle("text-success", "text-danger", textMessage);
}
} finally {
$button.prop("disabled", false);
}
}

async save() {
const redirectBareDomain = this.$widget.find('input[name="redirectBareDomain"]').prop("checked");
await this.updateOption<"redirectBareDomain">("redirectBareDomain", redirectBareDomain.toString());
// more than one currently shared Note found with the sharedRoot label
// → use the first found, but warn user about it
if (sharedShareRootNotes.length > 1) {

const foundNoteTitles = shareRootNotes.map(note => t("share.share_note_title", {
noteTitle: note.title,
interpolation: {
escapeValue: false
}
}));
const activeNoteTitle = foundNoteTitles[0];

return setCheckShareRootStyle("text-danger", "text-success",
t("share.share_root_multiple_found", {
activeNoteTitle,
foundNoteTitles: foundNoteTitles.join(", ")
})
);
}

const showLoginInShareTheme = this.$widget.find('input[name="showLoginInShareTheme"]').prop("checked");
await this.updateOption<"showLoginInShareTheme">("showLoginInShareTheme", showLoginInShareTheme.toString());
// exactly one note that has the sharedRoot label AND is currently shared
return setCheckShareRootStyle("text-danger", "text-success",
t("share.share_root_found", { noteTitle: sharedShareRootNotes[0].title })
);

} catch(err) {
console.error(err);
return setCheckShareRootStyle("text-success", "text-danger",
t("share.check_share_root_error",)
);
}
}
}
2 changes: 2 additions & 0 deletions apps/server/src/routes/api/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"allowedHtmlTags",
"redirectBareDomain",
"showLoginInShareTheme",
"shareSubtree",
"sharePath",
"splitEditorOrientation",

// AI/LLM integration options
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/routes/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const GET = "get",
DEL = "delete";

function register(app: express.Application) {

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
18 changes: 17 additions & 1 deletion apps/server/src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,27 @@ 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();
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/services/options_init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,10 @@ const defaultOptions: DefaultOption[] = [
},

// Share settings
{ name: "sharePath", value: "/share", isSynced: true },
{ name: "redirectBareDomain", value: "false", isSynced: true },
{ name: "showLoginInShareTheme", value: "false", isSynced: true },
{ name: "shareSubtree", value: "false", isSynced: true },

// AI Options
{ name: "aiEnabled", value: "false", isSynced: true },
Expand Down
Loading