tfrere HF Staff commited on
Commit
34f4083
·
1 Parent(s): c49b156

feat: auto-create private HF dataset using OAuth token

Browse files

- No more need for HF_TOKEN secret; the logged-in user's OAuth token
(with write-repos scope) is used for all HF API calls
- Dataset ID is derived from SPACE_ID (<space>-data) when not explicit
- Dataset is created automatically (private) on first write operation
- Last valid OAuth token is cached in memory for background operations
(debounced document push)

Made-with: Cursor

README.md CHANGED
@@ -7,6 +7,8 @@ sdk: docker
7
  app_port: 8080
8
  pinned: false
9
  hf_oauth: true
 
 
10
  ---
11
 
12
  # Collab Editor
 
7
  app_port: 8080
8
  pinned: false
9
  hf_oauth: true
10
+ hf_oauth_scopes:
11
+ - write-repos
12
  ---
13
 
14
  # Collab Editor
backend/src/hf-storage.ts CHANGED
@@ -1,12 +1,60 @@
1
- import { uploadFile, downloadFile, type RepoDesignation } from "@huggingface/hub";
2
 
3
- const HF_TOKEN = process.env.HF_TOKEN || "";
4
- const HF_DATASET_ID = process.env.HF_DATASET_ID || "";
 
 
 
 
5
 
6
  const repo: RepoDesignation = { type: "dataset", name: HF_DATASET_ID };
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  export function isHfStorageEnabled(): boolean {
9
- return Boolean(HF_TOKEN && HF_DATASET_ID);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  }
11
 
12
  function sanitizeName(name: string): string {
@@ -18,13 +66,15 @@ function sanitizeName(name: string): string {
18
  export async function uploadImageToHf(
19
  buffer: Buffer,
20
  filename: string,
 
21
  ): Promise<string> {
22
- const path = `images/${filename}`;
23
 
 
24
  await uploadFile({
25
  repo,
26
  file: { path, content: new Blob([new Uint8Array(buffer)]) },
27
- accessToken: HF_TOKEN,
28
  commitTitle: `upload image ${filename}`,
29
  });
30
 
@@ -36,10 +86,6 @@ export async function uploadImageToHf(
36
  const dirtyDocs = new Map<string, { state: Buffer; timer: NodeJS.Timeout }>();
37
  const DEBOUNCE_MS = 10_000;
38
 
39
- /**
40
- * Mark a document as dirty. After DEBOUNCE_MS of inactivity,
41
- * push the latest state to HF.
42
- */
43
  export function schedulePush(docName: string, state: Buffer): void {
44
  const existing = dirtyDocs.get(docName);
45
  if (existing) clearTimeout(existing.timer);
@@ -53,6 +99,8 @@ async function pushDocument(docName: string): Promise<void> {
53
  if (!entry) return;
54
  dirtyDocs.delete(docName);
55
 
 
 
56
  const safeName = sanitizeName(docName);
57
  const path = `articles/${safeName}.yjs`;
58
 
@@ -60,7 +108,7 @@ async function pushDocument(docName: string): Promise<void> {
60
  await uploadFile({
61
  repo,
62
  file: { path, content: new Blob([new Uint8Array(entry.state)]) },
63
- accessToken: HF_TOKEN,
64
  commitTitle: `save ${safeName}`,
65
  });
66
  console.log(`[hf-storage] pushed ${path}`);
@@ -77,7 +125,7 @@ export async function pullDocument(docName: string): Promise<Uint8Array | null>
77
  const res = await downloadFile({
78
  repo,
79
  path,
80
- accessToken: HF_TOKEN,
81
  });
82
  if (!res) return null;
83
  const arrayBuf = await res.arrayBuffer();
@@ -87,10 +135,6 @@ export async function pullDocument(docName: string): Promise<Uint8Array | null>
87
  }
88
  }
89
 
90
- /**
91
- * Flush all pending documents to HF immediately.
92
- * Call this on graceful shutdown.
93
- */
94
  export async function flushAll(): Promise<void> {
95
  const names = [...dirtyDocs.keys()];
96
  await Promise.allSettled(names.map((n) => pushDocument(n)));
@@ -107,16 +151,19 @@ interface PublishedPayload {
107
 
108
  export async function uploadPublishedAssets(
109
  docName: string,
110
- payload: PublishedPayload
 
111
  ): Promise<{ htmlUrl: string; pdfUrl: string | null; thumbUrl: string | null }> {
 
 
 
112
  const safeName = sanitizeName(docName);
113
  const base = `published/${safeName}`;
114
 
115
- // Upload HTML
116
  await uploadFile({
117
  repo,
118
  file: { path: `${base}/index.html`, content: new Blob([payload.html]) },
119
- accessToken: HF_TOKEN,
120
  commitTitle: `publish ${safeName}: HTML`,
121
  });
122
 
@@ -124,36 +171,33 @@ export async function uploadPublishedAssets(
124
  let pdfUrl: string | null = null;
125
  let thumbUrl: string | null = null;
126
 
127
- // Upload PDF
128
  if (payload.pdf) {
129
  await uploadFile({
130
  repo,
131
  file: { path: `${base}/article.pdf`, content: new Blob([new Uint8Array(payload.pdf)]) },
132
- accessToken: HF_TOKEN,
133
  commitTitle: `publish ${safeName}: PDF`,
134
  });
135
  pdfUrl = `${baseUrl}/article.pdf`;
136
  }
137
 
138
- // Upload thumbnail
139
  if (payload.thumbnail) {
140
  await uploadFile({
141
  repo,
142
  file: { path: `${base}/thumb.jpg`, content: new Blob([new Uint8Array(payload.thumbnail)]) },
143
- accessToken: HF_TOKEN,
144
  commitTitle: `publish ${safeName}: thumbnail`,
145
  });
146
  thumbUrl = `${baseUrl}/thumb.jpg`;
147
  }
148
 
149
- // Upload meta.json
150
  await uploadFile({
151
  repo,
152
  file: {
153
  path: `${base}/meta.json`,
154
  content: new Blob([JSON.stringify(payload.meta, null, 2)]),
155
  },
156
- accessToken: HF_TOKEN,
157
  commitTitle: `publish ${safeName}: metadata`,
158
  });
159
 
 
1
+ import { uploadFile, downloadFile, createRepo, type RepoDesignation } from "@huggingface/hub";
2
 
3
+ const SPACE_ID = process.env.SPACE_ID || "";
4
+
5
+ // Derive dataset ID: explicit env > "<space-id>-data"
6
+ const HF_DATASET_ID =
7
+ process.env.HF_DATASET_ID ||
8
+ (SPACE_ID ? `${SPACE_ID}-data` : "");
9
 
10
  const repo: RepoDesignation = { type: "dataset", name: HF_DATASET_ID };
11
 
12
+ // Fall-back token from env (optional); overridden by user OAuth tokens at runtime
13
+ const ENV_TOKEN = process.env.HF_TOKEN || "";
14
+
15
+ // Last known valid OAuth token, kept in memory for background operations
16
+ let _cachedToken = ENV_TOKEN;
17
+
18
+ export function getDatasetId(): string {
19
+ return HF_DATASET_ID;
20
+ }
21
+
22
+ export function setUserToken(token: string): void {
23
+ if (token) _cachedToken = token;
24
+ }
25
+
26
+ function getToken(explicit?: string): string {
27
+ return explicit || _cachedToken || ENV_TOKEN;
28
+ }
29
+
30
  export function isHfStorageEnabled(): boolean {
31
+ return Boolean(HF_DATASET_ID && (_cachedToken || ENV_TOKEN));
32
+ }
33
+
34
+ /**
35
+ * Ensure the backing dataset exists on HF (private).
36
+ * Safe to call multiple times; ignores "already exists".
37
+ */
38
+ let _datasetReady = false;
39
+
40
+ export async function ensureDatasetExists(token?: string): Promise<void> {
41
+ if (_datasetReady || !HF_DATASET_ID) return;
42
+
43
+ const accessToken = getToken(token);
44
+ if (!accessToken) return;
45
+
46
+ try {
47
+ await createRepo({ repo, private: true, accessToken });
48
+ console.log(`[hf-storage] created private dataset ${HF_DATASET_ID}`);
49
+ } catch (err: any) {
50
+ if (err?.statusCode === 409 || String(err?.message).includes("already")) {
51
+ console.log(`[hf-storage] dataset ${HF_DATASET_ID} already exists`);
52
+ } else {
53
+ console.error(`[hf-storage] failed to create dataset:`, err);
54
+ return;
55
+ }
56
+ }
57
+ _datasetReady = true;
58
  }
59
 
60
  function sanitizeName(name: string): string {
 
66
  export async function uploadImageToHf(
67
  buffer: Buffer,
68
  filename: string,
69
+ token?: string,
70
  ): Promise<string> {
71
+ await ensureDatasetExists(token);
72
 
73
+ const path = `images/${filename}`;
74
  await uploadFile({
75
  repo,
76
  file: { path, content: new Blob([new Uint8Array(buffer)]) },
77
+ accessToken: getToken(token),
78
  commitTitle: `upload image ${filename}`,
79
  });
80
 
 
86
  const dirtyDocs = new Map<string, { state: Buffer; timer: NodeJS.Timeout }>();
87
  const DEBOUNCE_MS = 10_000;
88
 
 
 
 
 
89
  export function schedulePush(docName: string, state: Buffer): void {
90
  const existing = dirtyDocs.get(docName);
91
  if (existing) clearTimeout(existing.timer);
 
99
  if (!entry) return;
100
  dirtyDocs.delete(docName);
101
 
102
+ await ensureDatasetExists();
103
+
104
  const safeName = sanitizeName(docName);
105
  const path = `articles/${safeName}.yjs`;
106
 
 
108
  await uploadFile({
109
  repo,
110
  file: { path, content: new Blob([new Uint8Array(entry.state)]) },
111
+ accessToken: getToken(),
112
  commitTitle: `save ${safeName}`,
113
  });
114
  console.log(`[hf-storage] pushed ${path}`);
 
125
  const res = await downloadFile({
126
  repo,
127
  path,
128
+ accessToken: getToken(),
129
  });
130
  if (!res) return null;
131
  const arrayBuf = await res.arrayBuffer();
 
135
  }
136
  }
137
 
 
 
 
 
138
  export async function flushAll(): Promise<void> {
139
  const names = [...dirtyDocs.keys()];
140
  await Promise.allSettled(names.map((n) => pushDocument(n)));
 
151
 
152
  export async function uploadPublishedAssets(
153
  docName: string,
154
+ payload: PublishedPayload,
155
+ token?: string,
156
  ): Promise<{ htmlUrl: string; pdfUrl: string | null; thumbUrl: string | null }> {
157
+ await ensureDatasetExists(token);
158
+
159
+ const accessToken = getToken(token);
160
  const safeName = sanitizeName(docName);
161
  const base = `published/${safeName}`;
162
 
 
163
  await uploadFile({
164
  repo,
165
  file: { path: `${base}/index.html`, content: new Blob([payload.html]) },
166
+ accessToken,
167
  commitTitle: `publish ${safeName}: HTML`,
168
  });
169
 
 
171
  let pdfUrl: string | null = null;
172
  let thumbUrl: string | null = null;
173
 
 
174
  if (payload.pdf) {
175
  await uploadFile({
176
  repo,
177
  file: { path: `${base}/article.pdf`, content: new Blob([new Uint8Array(payload.pdf)]) },
178
+ accessToken,
179
  commitTitle: `publish ${safeName}: PDF`,
180
  });
181
  pdfUrl = `${baseUrl}/article.pdf`;
182
  }
183
 
 
184
  if (payload.thumbnail) {
185
  await uploadFile({
186
  repo,
187
  file: { path: `${base}/thumb.jpg`, content: new Blob([new Uint8Array(payload.thumbnail)]) },
188
+ accessToken,
189
  commitTitle: `publish ${safeName}: thumbnail`,
190
  });
191
  thumbUrl = `${baseUrl}/thumb.jpg`;
192
  }
193
 
 
194
  await uploadFile({
195
  repo,
196
  file: {
197
  path: `${base}/meta.json`,
198
  content: new Blob([JSON.stringify(payload.meta, null, 2)]),
199
  },
200
+ accessToken,
201
  commitTitle: `publish ${safeName}: metadata`,
202
  });
203
 
backend/src/publisher/index.ts CHANGED
@@ -91,7 +91,7 @@ export interface PublishResult {
91
  /**
92
  * Run the full publish pipeline for a document.
93
  */
94
- export async function publishDocument(docName: string): Promise<PublishResult> {
95
  const p = docPath(docName);
96
 
97
  if (!existsSync(p)) {
@@ -144,7 +144,7 @@ export async function publishDocument(docName: string): Promise<PublishResult> {
144
  pdf,
145
  thumbnail,
146
  meta: meta as any,
147
- });
148
  return { ...urls, success: true };
149
  } catch (err) {
150
  console.error("[publish] Upload failed:", err);
 
91
  /**
92
  * Run the full publish pipeline for a document.
93
  */
94
+ export async function publishDocument(docName: string, token?: string): Promise<PublishResult> {
95
  const p = docPath(docName);
96
 
97
  if (!existsSync(p)) {
 
144
  pdf,
145
  thumbnail,
146
  meta: meta as any,
147
+ }, token);
148
  return { ...urls, success: true };
149
  } catch (err) {
150
  console.error("[publish] Upload failed:", err);
backend/src/server.ts CHANGED
@@ -13,6 +13,9 @@ import { handleChat } from "./agent/chat.js";
13
  import { citationsRouter } from "./citations.js";
14
  import {
15
  isHfStorageEnabled,
 
 
 
16
  uploadImageToHf,
17
  pullDocument,
18
  schedulePush,
@@ -34,11 +37,12 @@ function docPath(name: string) {
34
  return join(DATA_DIR, `${name.replace(/[^a-zA-Z0-9_-]/g, "_")}.yjs`);
35
  }
36
 
37
- const hfEnabled = isHfStorageEnabled();
38
- if (hfEnabled) {
39
  console.log("[server] HF Dataset persistence enabled");
 
 
40
  } else {
41
- console.log("[server] HF Dataset persistence disabled (no HF_TOKEN / HF_DATASET_ID)");
42
  console.log("[server] falling back to local file persistence");
43
  }
44
 
@@ -62,12 +66,10 @@ const hocuspocus = new Hocuspocus({
62
  extensions: [
63
  new Database({
64
  fetch: async ({ documentName }: { documentName: string }) => {
65
- // Try local cache first
66
  const p = docPath(documentName);
67
  if (existsSync(p)) return readFileSync(p);
68
 
69
- // Then try HF dataset
70
- if (hfEnabled) {
71
  const data = await pullDocument(documentName);
72
  if (data) {
73
  writeFileSync(p, data);
@@ -85,11 +87,9 @@ const hocuspocus = new Hocuspocus({
85
  documentName: string;
86
  state: Buffer;
87
  }) => {
88
- // Always write locally for fast access
89
  writeFileSync(docPath(documentName), state);
90
 
91
- // Schedule debounced push to HF
92
- if (hfEnabled) {
93
  schedulePush(documentName, state);
94
  }
95
  },
@@ -124,6 +124,11 @@ app.get("/api/auth/status", async (req, res) => {
124
  return;
125
  }
126
 
 
 
 
 
 
127
  res.json({
128
  authenticated: true,
129
  canEdit: user.canEdit,
@@ -152,7 +157,8 @@ app.use("/api/citations", citationsRouter);
152
  // ---------- Publish ----------
153
 
154
  app.post("/api/publish", async (req, res) => {
155
- // Auth check: only editors can publish
 
156
  if (oauthEnabled) {
157
  const token = extractToken(req.headers.cookie);
158
  const user = await resolveUser(token);
@@ -166,6 +172,8 @@ app.post("/api/publish", async (req, res) => {
166
  res.status(403).json({ error: "Unauthorized: write access required" });
167
  return;
168
  }
 
 
169
  }
170
 
171
  try {
@@ -179,7 +187,7 @@ app.post("/api/publish", async (req, res) => {
179
  }
180
  await conn.disconnect();
181
 
182
- const result = await publishDocument(DEFAULT_DOC_NAME);
183
  if (!result.success) {
184
  res.status(500).json({ error: result.error || "Publish failed" });
185
  return;
@@ -206,8 +214,11 @@ app.post("/api/upload", upload.single("file"), async (req, res) => {
206
  const ext = file.originalname.split(".").pop() || "png";
207
  const filename = `${randomUUID()}.${ext}`;
208
 
209
- if (hfEnabled) {
210
- const url = await uploadImageToHf(file.buffer, filename);
 
 
 
211
  res.json({ url });
212
  return;
213
  }
@@ -238,18 +249,18 @@ app.get("/api/images/*", async (req, res) => {
238
  return;
239
  }
240
 
241
- const HF_DATASET_ID = process.env.HF_DATASET_ID || "";
242
- const HF_TOKEN = process.env.HF_TOKEN || "";
243
-
244
- if (!HF_DATASET_ID || !HF_TOKEN) {
245
  res.status(404).send("Not configured");
246
  return;
247
  }
248
 
 
 
249
  try {
250
- const url = `https://huggingface.co/datasets/${HF_DATASET_ID}/resolve/main/images/${imagePath}`;
251
  const response = await fetch(url, {
252
- headers: { Authorization: `Bearer ${HF_TOKEN}` },
253
  });
254
 
255
  if (!response.ok) {
 
13
  import { citationsRouter } from "./citations.js";
14
  import {
15
  isHfStorageEnabled,
16
+ ensureDatasetExists,
17
+ getDatasetId,
18
+ setUserToken,
19
  uploadImageToHf,
20
  pullDocument,
21
  schedulePush,
 
37
  return join(DATA_DIR, `${name.replace(/[^a-zA-Z0-9_-]/g, "_")}.yjs`);
38
  }
39
 
40
+ if (isHfStorageEnabled()) {
 
41
  console.log("[server] HF Dataset persistence enabled");
42
+ } else if (getDatasetId()) {
43
+ console.log("[server] HF Dataset ID ready, waiting for first user login to activate persistence");
44
  } else {
45
+ console.log("[server] HF Dataset persistence disabled (no SPACE_ID or HF_DATASET_ID)");
46
  console.log("[server] falling back to local file persistence");
47
  }
48
 
 
66
  extensions: [
67
  new Database({
68
  fetch: async ({ documentName }: { documentName: string }) => {
 
69
  const p = docPath(documentName);
70
  if (existsSync(p)) return readFileSync(p);
71
 
72
+ if (isHfStorageEnabled()) {
 
73
  const data = await pullDocument(documentName);
74
  if (data) {
75
  writeFileSync(p, data);
 
87
  documentName: string;
88
  state: Buffer;
89
  }) => {
 
90
  writeFileSync(docPath(documentName), state);
91
 
92
+ if (isHfStorageEnabled()) {
 
93
  schedulePush(documentName, state);
94
  }
95
  },
 
124
  return;
125
  }
126
 
127
+ // Cache the user's OAuth token for HF API calls (dataset storage)
128
+ if (user.canEdit && token) {
129
+ setUserToken(token);
130
+ }
131
+
132
  res.json({
133
  authenticated: true,
134
  canEdit: user.canEdit,
 
157
  // ---------- Publish ----------
158
 
159
  app.post("/api/publish", async (req, res) => {
160
+ let userToken: string | undefined;
161
+
162
  if (oauthEnabled) {
163
  const token = extractToken(req.headers.cookie);
164
  const user = await resolveUser(token);
 
172
  res.status(403).json({ error: "Unauthorized: write access required" });
173
  return;
174
  }
175
+ userToken = token ?? undefined;
176
+ if (userToken) setUserToken(userToken);
177
  }
178
 
179
  try {
 
187
  }
188
  await conn.disconnect();
189
 
190
+ const result = await publishDocument(DEFAULT_DOC_NAME, userToken);
191
  if (!result.success) {
192
  res.status(500).json({ error: result.error || "Publish failed" });
193
  return;
 
214
  const ext = file.originalname.split(".").pop() || "png";
215
  const filename = `${randomUUID()}.${ext}`;
216
 
217
+ const userToken = extractToken(req.headers.cookie) ?? undefined;
218
+ if (userToken) setUserToken(userToken);
219
+
220
+ if (isHfStorageEnabled()) {
221
+ const url = await uploadImageToHf(file.buffer, filename, userToken);
222
  res.json({ url });
223
  return;
224
  }
 
249
  return;
250
  }
251
 
252
+ const datasetId = getDatasetId();
253
+ if (!datasetId || !isHfStorageEnabled()) {
 
 
254
  res.status(404).send("Not configured");
255
  return;
256
  }
257
 
258
+ const userToken = extractToken(req.headers.cookie) ?? undefined;
259
+
260
  try {
261
+ const url = `https://huggingface.co/datasets/${datasetId}/resolve/main/images/${imagePath}`;
262
  const response = await fetch(url, {
263
+ headers: { Authorization: `Bearer ${userToken || ""}` },
264
  });
265
 
266
  if (!response.ok) {