Skip to content

Commit 1be01db

Browse files
authored
[ENG-1177] Authenticate Obsidian user for publishing (#656)
* current progress * some light cleanup * fix type error * address PR comments * redo the auth so that only one user acc is created
1 parent f6669c5 commit 1be01db

File tree

13 files changed

+329
-92
lines changed

13 files changed

+329
-92
lines changed

apps/obsidian/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
},
3838
"dependencies": {
3939
"@codemirror/view": "^6.38.8",
40+
"@repo/database": "workspace:*",
41+
"@repo/utils": "workspace:*",
42+
"@supabase/supabase-js": "catalog:",
4043
"date-fns": "^4.1.0",
4144
"nanoid": "^4.0.2",
4245
"react": "catalog:obsidian",

apps/obsidian/scripts/compile.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
12
import esbuild from "esbuild";
23
import fs from "fs";
34
import path from "path";
@@ -10,6 +11,19 @@ import autoprefixer from "autoprefixer";
1011

1112
dotenv.config();
1213

14+
// For local dev: Set SUPABASE_USE_DB=local and run `pnpm run genenv` in packages/database
15+
let envContents: (() => Record<string, string>) | null = null;
16+
try {
17+
const dbDotEnv = require("@repo/database/dbDotEnv");
18+
envContents = dbDotEnv.envContents;
19+
} catch (error) {
20+
if ((error as Error).message.includes("Cannot find module")) {
21+
console.error("Build the database module before compiling obsidian");
22+
process.exit(1);
23+
}
24+
throw error;
25+
}
26+
1327
const DEFAULT_FILES_INCLUDED = ["manifest.json"];
1428
const isProd = process.env.NODE_ENV === "production";
1529

@@ -43,6 +57,7 @@ export const args = {
4357
"@lezer/common",
4458
"@lezer/highlight",
4559
"@lezer/lr",
60+
"tslib=window.TSLib",
4661
...builtins,
4762
],
4863
} as CliOpts;
@@ -89,6 +104,10 @@ export const compile = ({
89104
fs.mkdirSync(outdir, { recursive: true });
90105

91106
const buildPromises = [] as Promise<void>[];
107+
if (!envContents) {
108+
throw new Error("envContents not loaded. Build the database module first.");
109+
}
110+
const dbEnv = envContents();
92111
buildPromises.push(
93112
builder({
94113
absWorkingDir: process.cwd(),
@@ -100,6 +119,15 @@ export const compile = ({
100119
minify: isProd,
101120
entryNames: out,
102121
external: external,
122+
define: {
123+
"process.env.SUPABASE_URL": dbEnv.SUPABASE_URL
124+
? `"${dbEnv.SUPABASE_URL}"`
125+
: "null",
126+
"process.env.SUPABASE_ANON_KEY": dbEnv.SUPABASE_ANON_KEY
127+
? `"${dbEnv.SUPABASE_ANON_KEY}"`
128+
: "null",
129+
"process.env.NEXT_API_ROOT": `"${dbEnv.NEXT_API_ROOT || ""}"`,
130+
},
103131
plugins: [
104132
{
105133
name: "log",

apps/obsidian/src/components/AdminPanelSettings.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,57 @@
1-
import { useState } from "react";
1+
import { useState, useCallback } from "react";
22
import { usePlugin } from "./PluginContext";
33
import { Notice } from "obsidian";
4+
import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase";
45

56
export const AdminPanelSettings = () => {
67
const plugin = usePlugin();
8+
const [syncModeEnabled, setSyncModeEnabled] = useState<boolean>(
9+
plugin.settings.syncModeEnabled ?? false,
10+
);
711
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
812

13+
const handleSyncModeToggle = useCallback((newValue: boolean) => {
14+
setSyncModeEnabled(newValue);
15+
setHasUnsavedChanges(true);
16+
}, []);
17+
918
const handleSave = async () => {
19+
plugin.settings.syncModeEnabled = syncModeEnabled;
1020
await plugin.saveSettings();
1121
new Notice("Admin panel settings saved");
1222
setHasUnsavedChanges(false);
23+
24+
if (syncModeEnabled) {
25+
try {
26+
await initializeSupabaseSync(plugin);
27+
new Notice("Sync mode initialized successfully");
28+
} catch (error) {
29+
console.error("Failed to initialize sync mode:", error);
30+
new Notice(
31+
`Failed to initialize sync mode: ${error instanceof Error ? error.message : String(error)}`,
32+
);
33+
}
34+
}
1335
};
1436

1537
return (
1638
<div className="general-settings">
17-
{/* Add more admin panel settings sections here */}
39+
<div className="setting-item">
40+
<div className="setting-item-info">
41+
<div className="setting-item-name">(BETA) Sync mode enable</div>
42+
<div className="setting-item-description">
43+
Enable synchronization with Discourse Graph database
44+
</div>
45+
</div>
46+
<div className="setting-item-control">
47+
<div
48+
className={`checkbox-container ${syncModeEnabled ? "is-enabled" : ""}`}
49+
onClick={() => handleSyncModeToggle(!syncModeEnabled)}
50+
>
51+
<input type="checkbox" checked={syncModeEnabled} />
52+
</div>
53+
</div>
54+
</div>
1855

1956
<div className="setting-item">
2057
<button

apps/obsidian/src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export const DEFAULT_SETTINGS: Settings = {
7070
canvasFolderPath: "Discourse Canvas",
7171
canvasAttachmentsFolderPath: "attachments",
7272
nodeTagHotkey: "\\",
73+
spacePassword: undefined,
74+
accountLocalId: undefined,
75+
syncModeEnabled: false,
7376
};
7477

7578
export const FEATURE_FLAGS = {

apps/obsidian/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
TFile,
77
MarkdownView,
88
WorkspaceLeaf,
9+
Notice,
910
} from "obsidian";
1011
import { EditorView } from "@codemirror/view";
1112
import { SettingsTab } from "~/components/Settings";
@@ -22,6 +23,7 @@ import ModifyNodeModal from "~/components/ModifyNodeModal";
2223
import { TagNodeHandler } from "~/utils/tagNodeHandler";
2324
import { TldrawView } from "~/components/canvas/TldrawView";
2425
import { NodeTagSuggestPopover } from "~/components/NodeTagSuggestModal";
26+
import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase";
2527

2628
export default class DiscourseGraphPlugin extends Plugin {
2729
settings: Settings = { ...DEFAULT_SETTINGS };
@@ -32,6 +34,17 @@ export default class DiscourseGraphPlugin extends Plugin {
3234

3335
async onload() {
3436
await this.loadSettings();
37+
38+
if (this.settings.syncModeEnabled === true) {
39+
void initializeSupabaseSync(this).catch((error) => {
40+
console.error("Failed to initialize Supabase sync:", error);
41+
new Notice(
42+
`Failed to initialize Supabase sync: ${error instanceof Error ? error.message : String(error)}`,
43+
5000,
44+
);
45+
});
46+
}
47+
3548
registerCommands(this);
3649
this.addSettingTab(new SettingsTab(this.app, this));
3750

apps/obsidian/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export type Settings = {
3434
canvasFolderPath: string;
3535
canvasAttachmentsFolderPath: string;
3636
nodeTagHotkey: string;
37+
spacePassword?: string;
38+
accountLocalId?: string;
39+
syncModeEnabled?: boolean;
3740
};
3841

3942
export type BulkImportCandidate = {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type { Enums } from "@repo/database/dbTypes";
2+
import type { DGSupabaseClient } from "@repo/database/lib/client";
3+
import {
4+
fetchOrCreateSpaceDirect,
5+
fetchOrCreatePlatformAccount,
6+
createLoggedInClient,
7+
} from "@repo/database/lib/contextFunctions";
8+
import type DiscourseGraphPlugin from "~/index";
9+
10+
type Platform = Enums<"Platform">;
11+
12+
export type SupabaseContext = {
13+
platform: Platform;
14+
spaceId: number;
15+
userId: number;
16+
spacePassword: string;
17+
};
18+
19+
let contextCache: SupabaseContext | null = null;
20+
21+
const generateAccountLocalId = (vaultName: string): string => {
22+
const randomSuffix = Math.random().toString(36).substring(2, 8).toUpperCase();
23+
const sanitizedVaultName = vaultName
24+
.replace(/\s+/g, "")
25+
.replace(/[^a-zA-Z0-9]/g, "")
26+
.replace(/-+/g, "-");
27+
return `${sanitizedVaultName}${randomSuffix}`;
28+
};
29+
30+
const getOrCreateSpacePassword = async (
31+
plugin: DiscourseGraphPlugin,
32+
): Promise<string> => {
33+
if (plugin.settings.spacePassword) {
34+
return plugin.settings.spacePassword;
35+
}
36+
const password = crypto.randomUUID();
37+
plugin.settings.spacePassword = password;
38+
await plugin.saveSettings();
39+
return password;
40+
};
41+
42+
const getOrCreateAccountLocalId = async (
43+
plugin: DiscourseGraphPlugin,
44+
vaultName: string,
45+
): Promise<string> => {
46+
if (plugin.settings.accountLocalId) {
47+
return plugin.settings.accountLocalId;
48+
}
49+
const accountLocalId = generateAccountLocalId(vaultName);
50+
plugin.settings.accountLocalId = accountLocalId;
51+
await plugin.saveSettings();
52+
return accountLocalId;
53+
};
54+
55+
/**
56+
* Gets the unique vault ID from Obsidian's internal API.
57+
* @see https://help.obsidian.md/Extending+Obsidian/Obsidian+URI
58+
*/
59+
const getVaultId = (app: DiscourseGraphPlugin["app"]): string => {
60+
return (app as unknown as { appId: string }).appId;
61+
};
62+
63+
const canonicalObsidianUrl = (vaultId: string): string => {
64+
return `obsidian:${vaultId}`;
65+
};
66+
67+
export const getSupabaseContext = async (
68+
plugin: DiscourseGraphPlugin,
69+
): Promise<SupabaseContext | null> => {
70+
if (contextCache === null) {
71+
try {
72+
const vaultName = plugin.app.vault.getName() || "obsidian-vault";
73+
const vaultId = getVaultId(plugin.app);
74+
75+
const spacePassword = await getOrCreateSpacePassword(plugin);
76+
const accountLocalId = await getOrCreateAccountLocalId(plugin, vaultName);
77+
78+
const url = canonicalObsidianUrl(vaultId);
79+
const platform: Platform = "Obsidian";
80+
81+
const spaceResult = await fetchOrCreateSpaceDirect({
82+
password: spacePassword,
83+
url,
84+
name: vaultName,
85+
platform,
86+
});
87+
88+
if (!spaceResult.data) {
89+
console.error("Failed to create space");
90+
return null;
91+
}
92+
93+
const spaceId = spaceResult.data.id;
94+
const userId = await fetchOrCreatePlatformAccount({
95+
platform: "Obsidian",
96+
accountLocalId,
97+
name: vaultName,
98+
email: accountLocalId,
99+
spaceId,
100+
password: spacePassword,
101+
});
102+
103+
contextCache = {
104+
platform: "Obsidian",
105+
spaceId,
106+
userId,
107+
spacePassword,
108+
};
109+
} catch (error) {
110+
console.error(error);
111+
return null;
112+
}
113+
}
114+
return contextCache;
115+
};
116+
117+
let loggedInClient: DGSupabaseClient | null = null;
118+
119+
export const getLoggedInClient = async (
120+
plugin: DiscourseGraphPlugin,
121+
): Promise<DGSupabaseClient | null> => {
122+
if (loggedInClient === null) {
123+
const context = await getSupabaseContext(plugin);
124+
if (context === null) {
125+
throw new Error("Could not create Supabase context");
126+
}
127+
try {
128+
loggedInClient = await createLoggedInClient({
129+
platform: context.platform,
130+
spaceId: context.spaceId,
131+
password: context.spacePassword,
132+
});
133+
if (!loggedInClient) {
134+
throw new Error(
135+
"Failed to create Supabase client - check environment variables",
136+
);
137+
}
138+
} catch (error) {
139+
const errorMessage =
140+
error instanceof Error ? error.message : String(error);
141+
console.error("Failed to create logged-in client:", errorMessage);
142+
throw new Error(`Supabase authentication failed: ${errorMessage}`);
143+
}
144+
} else {
145+
// renew session
146+
const { error } = await loggedInClient.auth.getSession();
147+
if (error) {
148+
console.warn("Session renewal failed, re-authenticating:", error);
149+
loggedInClient = null;
150+
const context = await getSupabaseContext(plugin);
151+
if (context === null) {
152+
throw new Error(
153+
"Could not create Supabase context for re-authentication",
154+
);
155+
}
156+
157+
loggedInClient = await createLoggedInClient({
158+
platform: context.platform,
159+
spaceId: context.spaceId,
160+
password: context.spacePassword,
161+
});
162+
if (!loggedInClient) {
163+
throw new Error("Failed to re-authenticate Supabase client");
164+
}
165+
}
166+
}
167+
return loggedInClient;
168+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getSupabaseContext } from "./supabaseContext";
2+
import type DiscourseGraphPlugin from "~/index";
3+
4+
export const initializeSupabaseSync = async (
5+
plugin: DiscourseGraphPlugin,
6+
): Promise<void> => {
7+
const context = await getSupabaseContext(plugin);
8+
if (!context) {
9+
throw new Error("Failed to initialize Supabase sync: could not create context");
10+
}
11+
console.log("Supabase sync initialized successfully", {
12+
spaceId: context.spaceId,
13+
userId: context.userId,
14+
});
15+
};

apps/roam/src/utils/supabaseContext.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,11 @@ export const getLoggedInClient = async (): Promise<DGSupabaseClient | null> => {
9696
if (_loggedInClient === null) {
9797
const context = await getSupabaseContext();
9898
if (context === null) throw new Error("Could not create context");
99-
_loggedInClient = await createLoggedInClient(
100-
context.platform,
101-
context.spaceId,
102-
context.spacePassword,
103-
);
99+
_loggedInClient = await createLoggedInClient({
100+
platform: context.platform,
101+
spaceId: context.spaceId,
102+
password: context.spacePassword,
103+
});
104104
} else {
105105
// renew session
106106
const { error } = await _loggedInClient.auth.getSession();

packages/database/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
"@repo/utils": "workspace:*",
4646
"@supabase/auth-js": "catalog:",
4747
"@supabase/functions-js": "catalog:",
48-
"@supabase/supabase-js": "catalog:"
48+
"@supabase/supabase-js": "catalog:",
49+
"tslib": "2.5.1"
4950
},
5051
"devDependencies": {
5152
"@cucumber/cucumber": "^12.1.0",

0 commit comments

Comments
 (0)