openwriter 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/crimson-pro-latin-wght-italic-CkPs-Rv6.woff2 +0 -0
- package/dist/client/assets/crimson-pro-latin-wght-normal-C4R2PX-h.woff2 +0 -0
- package/dist/client/assets/dm-sans-latin-wght-italic-Cz4n9dED.woff2 +0 -0
- package/dist/client/assets/dm-sans-latin-wght-normal-Xz1IZZA0.woff2 +0 -0
- package/dist/client/assets/dm-serif-display-latin-400-italic-DpcbibHm.woff2 +0 -0
- package/dist/client/assets/dm-serif-display-latin-400-normal-C5_t9oOD.woff2 +0 -0
- package/dist/client/assets/ibm-plex-mono-latin-400-italic-BqAiT5Ww.woff2 +0 -0
- package/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/client/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/client/assets/ibm-plex-sans-latin-400-italic-CZTNEAuW.woff2 +0 -0
- package/dist/client/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/client/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/client/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/client/assets/index-DiJrXBgV.css +1 -0
- package/dist/client/assets/index-vmEPerKn.js +215 -0
- package/dist/client/assets/inter-latin-wght-italic-DpCbqKDY.woff2 +0 -0
- package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/client/assets/libre-baskerville-latin-400-italic-Dx5Rrf3o.woff2 +0 -0
- package/dist/client/assets/libre-baskerville-latin-400-normal-C42RasBZ.woff2 +0 -0
- package/dist/client/assets/libre-baskerville-latin-700-normal-CLGq6Yj4.woff2 +0 -0
- package/dist/client/assets/literata-latin-wght-italic-Bm_GJfSc.woff2 +0 -0
- package/dist/client/assets/literata-latin-wght-normal-DLxlUchJ.woff2 +0 -0
- package/dist/client/assets/newsreader-latin-wght-italic-Bxi8ein9.woff2 +0 -0
- package/dist/client/assets/newsreader-latin-wght-normal-CCVVNp6i.woff2 +0 -0
- package/dist/client/assets/playfair-display-latin-wght-italic-DmbndNpe.woff2 +0 -0
- package/dist/client/assets/playfair-display-latin-wght-normal-BOwq7MWX.woff2 +0 -0
- package/dist/client/assets/source-serif-4-latin-wght-italic-D2yaqPoE.woff2 +0 -0
- package/dist/client/assets/source-serif-4-latin-wght-normal-D9elroTD.woff2 +0 -0
- package/dist/client/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
- package/dist/client/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
- package/dist/client/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
- package/dist/client/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
- package/dist/client/index.html +5 -5
- package/dist/plugins/github/dist/blog-tools.js +8 -2
- package/dist/plugins/github/dist/git-sync.d.ts +11 -1
- package/dist/plugins/github/dist/git-sync.js +73 -14
- package/dist/plugins/github/dist/index.js +15 -5
- package/dist/plugins/x-api/dist/index.js +130 -2
- package/dist/plugins/x-api/dist/server-bridge.d.ts +22 -0
- package/dist/plugins/x-api/dist/server-bridge.js +43 -0
- package/dist/server/connection-routes.js +66 -0
- package/dist/server/tiptap-draftjs.js +296 -0
- package/dist/server/ws.js +8 -0
- package/package.json +14 -1
- package/skill/docs/footnotes.md +1 -1
- package/dist/client/assets/index-2miZWC8D.css +0 -1
- package/dist/client/assets/index-C_Zb7mUx.js +0 -215
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/client/index.html
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
|
8
8
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
9
9
|
<title>OpenWriter</title>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
10
|
+
<!-- Fonts are self-hosted (bundled via @fontsource, imported in main.tsx →
|
|
11
|
+
themes/vendored-fonts.css). No external font CDN: a local-first editor
|
|
12
|
+
must work offline and behind content blockers. -->
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-vmEPerKn.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DiJrXBgV.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -11,10 +11,16 @@ import { randomUUID } from 'crypto';
|
|
|
11
11
|
import { homedir } from 'os';
|
|
12
12
|
import { getServerModules, listBlogSites, writeBlogSites, } from './helpers.js';
|
|
13
13
|
const NETWORK_TIMEOUT = 60000;
|
|
14
|
+
// SECURITY (MCP-1): no shell. `git`/`gh` are spawned directly via execFile
|
|
15
|
+
// with an argv array, so every element — including attacker-influenced blog
|
|
16
|
+
// config values (owner/repo/branch) and slugs — is passed to the program as a
|
|
17
|
+
// single literal argument and is NEVER interpreted by a shell. Do NOT add
|
|
18
|
+
// `shell: true` or hand-roll arg quoting here: that reintroduces OS command
|
|
19
|
+
// injection. If a future call genuinely needs shell features, whitelist-
|
|
20
|
+
// validate the inputs instead.
|
|
14
21
|
function exec(cmd, args, cwd, timeout = NETWORK_TIMEOUT) {
|
|
15
|
-
const safeArgs = args.map(a => a.includes(' ') ? `"${a}"` : a);
|
|
16
22
|
return new Promise((resolve, reject) => {
|
|
17
|
-
execFile(cmd,
|
|
23
|
+
execFile(cmd, args, { cwd, timeout, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
18
24
|
if (err)
|
|
19
25
|
reject(new Error(stderr?.trim() || err.message));
|
|
20
26
|
else
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* Git sync module: all git/gh CLI interactions for GitHub docs backup.
|
|
3
3
|
* Lifted from packages/openwriter/server/git-sync.ts into the github plugin.
|
|
4
4
|
*
|
|
5
|
-
* Uses child_process.execFile with shell
|
|
5
|
+
* Uses child_process.execFile with an argv array and NO shell (MCP-1): values
|
|
6
|
+
* are passed to git/gh as literal arguments, never interpreted by a shell.
|
|
7
|
+
* The GitHub PAT is supplied to authenticated pushes out-of-band via an
|
|
8
|
+
* inline credential helper reading from an env var (MCP-3) — it is never
|
|
9
|
+
* embedded in the remote URL, written to .git/config, or placed in argv.
|
|
6
10
|
* Server-internal modules (state, helpers, ws) accessed via getServerModules().
|
|
7
11
|
*/
|
|
8
12
|
export type SyncState = 'unconfigured' | 'synced' | 'pending' | 'syncing' | 'error';
|
|
@@ -19,6 +23,12 @@ export interface SyncCapabilities {
|
|
|
19
23
|
existingRepo: boolean;
|
|
20
24
|
remoteUrl?: string;
|
|
21
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Strip any embedded credentials (PAT / user:pass) from a git remote URL so a
|
|
28
|
+
* credential-bearing URL is never returned to a client or logged. SSH scp-style
|
|
29
|
+
* remotes (git@github.com:owner/repo.git) carry no secret and are left as-is.
|
|
30
|
+
*/
|
|
31
|
+
export declare function sanitizeRemoteUrl(url: string): string;
|
|
22
32
|
export declare function isGitInstalled(): Promise<boolean>;
|
|
23
33
|
export declare function isGhInstalled(): Promise<boolean>;
|
|
24
34
|
export declare function isGhAuthenticated(): Promise<boolean>;
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* Git sync module: all git/gh CLI interactions for GitHub docs backup.
|
|
3
3
|
* Lifted from packages/openwriter/server/git-sync.ts into the github plugin.
|
|
4
4
|
*
|
|
5
|
-
* Uses child_process.execFile with shell
|
|
5
|
+
* Uses child_process.execFile with an argv array and NO shell (MCP-1): values
|
|
6
|
+
* are passed to git/gh as literal arguments, never interpreted by a shell.
|
|
7
|
+
* The GitHub PAT is supplied to authenticated pushes out-of-band via an
|
|
8
|
+
* inline credential helper reading from an env var (MCP-3) — it is never
|
|
9
|
+
* embedded in the remote URL, written to .git/config, or placed in argv.
|
|
6
10
|
* Server-internal modules (state, helpers, ws) accessed via getServerModules().
|
|
7
11
|
*/
|
|
8
12
|
import { execFile } from 'child_process';
|
|
@@ -13,10 +17,12 @@ const GITIGNORE_CONTENT = `config.json\n.versions/\n`;
|
|
|
13
17
|
const NETWORK_TIMEOUT = 30000;
|
|
14
18
|
let currentSyncState = 'unconfigured';
|
|
15
19
|
let lastError;
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
// SECURITY (MCP-1): no shell. Arguments are passed to git/gh as an argv array,
|
|
21
|
+
// so each element is a single literal argument with no shell interpretation.
|
|
22
|
+
// The optional `env` is merged over the parent process env for that one call.
|
|
23
|
+
function exec(cmd, args, cwd, timeout = 10000, env) {
|
|
18
24
|
return new Promise((resolve, reject) => {
|
|
19
|
-
execFile(cmd,
|
|
25
|
+
execFile(cmd, args, { cwd, timeout, env: env ? { ...process.env, ...env } : process.env }, (err, stdout, stderr) => {
|
|
20
26
|
if (err)
|
|
21
27
|
reject(new Error(stderr?.trim() || err.message));
|
|
22
28
|
else
|
|
@@ -24,6 +30,42 @@ function exec(cmd, args, cwd, timeout = 10000) {
|
|
|
24
30
|
});
|
|
25
31
|
});
|
|
26
32
|
}
|
|
33
|
+
// ── Credential handling (MCP-3) ─────────────────────────────────────────────
|
|
34
|
+
// The PAT is supplied to authenticated git pushes WITHOUT ever touching the
|
|
35
|
+
// remote URL, .git/config, or argv. An inline credential helper (run by git
|
|
36
|
+
// through its own sh) reads the token from the OW_GIT_PAT env var at call time;
|
|
37
|
+
// only the variable *name* appears in arguments, never the secret itself.
|
|
38
|
+
const PAT_CRED_HELPER = '!f() { test "$1" = get && printf "username=x-access-token\\npassword=%s\\n" "$OW_GIT_PAT"; }; f';
|
|
39
|
+
/**
|
|
40
|
+
* Run a git command that needs the PAT for network auth (push/fetch against a
|
|
41
|
+
* private remote). The `-c credential.helper=` first resets any inherited
|
|
42
|
+
* helper so only ours answers; the token is passed via env, not argv. The
|
|
43
|
+
* remote URL stays credential-free.
|
|
44
|
+
*/
|
|
45
|
+
function execGitWithPat(args, cwd, pat, timeout = NETWORK_TIMEOUT) {
|
|
46
|
+
const authArgs = ['-c', 'credential.helper=', '-c', `credential.helper=${PAT_CRED_HELPER}`];
|
|
47
|
+
return exec('git', [...authArgs, ...args], cwd, timeout, { OW_GIT_PAT: pat });
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Strip any embedded credentials (PAT / user:pass) from a git remote URL so a
|
|
51
|
+
* credential-bearing URL is never returned to a client or logged. SSH scp-style
|
|
52
|
+
* remotes (git@github.com:owner/repo.git) carry no secret and are left as-is.
|
|
53
|
+
*/
|
|
54
|
+
export function sanitizeRemoteUrl(url) {
|
|
55
|
+
try {
|
|
56
|
+
const u = new URL(url);
|
|
57
|
+
if (u.username || u.password) {
|
|
58
|
+
u.username = '';
|
|
59
|
+
u.password = '';
|
|
60
|
+
}
|
|
61
|
+
return u.toString();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Not a parseable URL (e.g. ssh scp-like form) — defensively drop any
|
|
65
|
+
// userinfo that precedes an @ in a scheme://… authority.
|
|
66
|
+
return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, '$1');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
27
69
|
async function dataDir() {
|
|
28
70
|
return (await getServerModules()).getDataDir();
|
|
29
71
|
}
|
|
@@ -127,7 +169,10 @@ export async function getCapabilities() {
|
|
|
127
169
|
let remoteUrl;
|
|
128
170
|
if (await isGitRepo()) {
|
|
129
171
|
try {
|
|
130
|
-
|
|
172
|
+
// MCP-3: never expose embedded credentials. Even though the remote is
|
|
173
|
+
// now stored credential-free, strip defensively so a legacy URL written
|
|
174
|
+
// by an older build (PAT-in-URL) can't leak through this route.
|
|
175
|
+
remoteUrl = sanitizeRemoteUrl(await exec('git', ['remote', 'get-url', 'origin'], await dataDir()));
|
|
131
176
|
}
|
|
132
177
|
catch { /* no remote */ }
|
|
133
178
|
}
|
|
@@ -199,7 +244,9 @@ export async function setupWithPat(pat, repoName, isPrivate) {
|
|
|
199
244
|
throw new Error(body.message || `GitHub API error: ${res.status}`);
|
|
200
245
|
}
|
|
201
246
|
const repo = await res.json();
|
|
202
|
-
|
|
247
|
+
// MCP-3: store a credential-free remote. The PAT is supplied per-push via
|
|
248
|
+
// the credential helper (execGitWithPat), never embedded in the URL.
|
|
249
|
+
const remoteUrl = `https://github.com/${repo.full_name}.git`;
|
|
203
250
|
await initRepo();
|
|
204
251
|
await initialCommit();
|
|
205
252
|
try {
|
|
@@ -207,7 +254,7 @@ export async function setupWithPat(pat, repoName, isPrivate) {
|
|
|
207
254
|
}
|
|
208
255
|
catch { /* no remote */ }
|
|
209
256
|
await exec('git', ['remote', 'add', 'origin', remoteUrl], dir);
|
|
210
|
-
await
|
|
257
|
+
await execGitWithPat(['push', '-u', 'origin', 'main'], dir, pat, NETWORK_TIMEOUT);
|
|
211
258
|
srv.saveConfig({
|
|
212
259
|
gitConfigured: true,
|
|
213
260
|
gitPat: pat,
|
|
@@ -222,20 +269,24 @@ export async function connectExisting(remoteUrl, pat) {
|
|
|
222
269
|
const dir = await dataDir();
|
|
223
270
|
await initRepo();
|
|
224
271
|
await initialCommit();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
272
|
+
// MCP-3: keep the remote credential-free; never splice the PAT into the URL.
|
|
273
|
+
// Strip any credentials the caller may have included before storing/using it.
|
|
274
|
+
const finalUrl = sanitizeRemoteUrl(remoteUrl);
|
|
229
275
|
try {
|
|
230
276
|
await exec('git', ['remote', 'remove', 'origin'], dir);
|
|
231
277
|
}
|
|
232
278
|
catch { /* no remote */ }
|
|
233
279
|
await exec('git', ['remote', 'add', 'origin', finalUrl], dir);
|
|
234
|
-
|
|
280
|
+
if (pat) {
|
|
281
|
+
await execGitWithPat(['push', '-u', 'origin', 'main'], dir, pat, NETWORK_TIMEOUT);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
await exec('git', ['push', '-u', 'origin', 'main'], dir, NETWORK_TIMEOUT);
|
|
285
|
+
}
|
|
235
286
|
srv.saveConfig({
|
|
236
287
|
gitConfigured: true,
|
|
237
288
|
gitPat: pat,
|
|
238
|
-
gitRemote:
|
|
289
|
+
gitRemote: finalUrl,
|
|
239
290
|
lastSyncTime: new Date().toISOString(),
|
|
240
291
|
});
|
|
241
292
|
currentSyncState = 'synced';
|
|
@@ -258,7 +309,15 @@ export async function pushSync(onStatus) {
|
|
|
258
309
|
});
|
|
259
310
|
await exec('git', ['commit', '-m', `Sync: ${timestamp}`], dir);
|
|
260
311
|
}
|
|
261
|
-
|
|
312
|
+
// MCP-3: the remote is credential-free. When configured via PAT, supply
|
|
313
|
+
// the token out-of-band per-push; gh-based / SSH remotes auth on their own.
|
|
314
|
+
const pat = srv.readConfig()?.gitPat;
|
|
315
|
+
if (pat) {
|
|
316
|
+
await execGitWithPat(['push'], dir, pat, NETWORK_TIMEOUT);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
await exec('git', ['push'], dir, NETWORK_TIMEOUT);
|
|
320
|
+
}
|
|
262
321
|
const now = new Date().toISOString();
|
|
263
322
|
srv.saveConfig({ lastSyncTime: now });
|
|
264
323
|
currentSyncState = 'synced';
|
|
@@ -32,12 +32,18 @@ const plugin = {
|
|
|
32
32
|
console.error('[GitHub Plugin] broadcast failed:', err.message);
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
|
+
// MCP-15: log the real error server-side; return a generic message to the
|
|
36
|
+
// client so internal details (paths, git output, tokens) never leak.
|
|
37
|
+
const fail = (res, where, err, extra = {}) => {
|
|
38
|
+
console.error(`[GitHub Plugin] ${where} failed:`, err?.message || err);
|
|
39
|
+
res.status(500).json({ error: 'Internal error', ...extra });
|
|
40
|
+
};
|
|
35
41
|
ctx.app.get('/api/sync/status', async (_req, res) => {
|
|
36
42
|
try {
|
|
37
43
|
res.json(await getSyncStatus());
|
|
38
44
|
}
|
|
39
45
|
catch (err) {
|
|
40
|
-
res
|
|
46
|
+
fail(res, 'sync/status', err, { state: 'error' });
|
|
41
47
|
}
|
|
42
48
|
});
|
|
43
49
|
ctx.app.get('/api/sync/capabilities', async (_req, res) => {
|
|
@@ -45,7 +51,7 @@ const plugin = {
|
|
|
45
51
|
res.json(await getCapabilities());
|
|
46
52
|
}
|
|
47
53
|
catch (err) {
|
|
48
|
-
res
|
|
54
|
+
fail(res, 'sync/capabilities', err);
|
|
49
55
|
}
|
|
50
56
|
});
|
|
51
57
|
ctx.app.get('/api/sync/pending', async (_req, res) => {
|
|
@@ -53,7 +59,7 @@ const plugin = {
|
|
|
53
59
|
res.json(await getPendingFiles());
|
|
54
60
|
}
|
|
55
61
|
catch (err) {
|
|
56
|
-
res
|
|
62
|
+
fail(res, 'sync/pending', err);
|
|
57
63
|
}
|
|
58
64
|
});
|
|
59
65
|
ctx.app.post('/api/sync/setup', async (req, res) => {
|
|
@@ -85,7 +91,11 @@ const plugin = {
|
|
|
85
91
|
res.json({ success: true, status });
|
|
86
92
|
}
|
|
87
93
|
catch (err) {
|
|
88
|
-
|
|
94
|
+
// Setup surfaces GitHub's own API feedback (e.g. "name already exists",
|
|
95
|
+
// "bad credentials") which is user-actionable; the remote is now
|
|
96
|
+
// credential-free so this carries no secret. Still log the full error.
|
|
97
|
+
console.error('[GitHub Plugin] sync/setup failed:', err?.message || err);
|
|
98
|
+
res.status(500).json({ error: err?.message || 'Setup failed' });
|
|
89
99
|
}
|
|
90
100
|
});
|
|
91
101
|
ctx.app.post('/api/sync/push', async (_req, res) => {
|
|
@@ -94,7 +104,7 @@ const plugin = {
|
|
|
94
104
|
res.json(result);
|
|
95
105
|
}
|
|
96
106
|
catch (err) {
|
|
97
|
-
res
|
|
107
|
+
fail(res, 'sync/push', err, { state: 'error' });
|
|
98
108
|
}
|
|
99
109
|
});
|
|
100
110
|
},
|
|
@@ -8,23 +8,48 @@ import { join, extname } from 'path';
|
|
|
8
8
|
import { readFileSync, existsSync } from 'fs';
|
|
9
9
|
import sharp from 'sharp';
|
|
10
10
|
import twitter from 'twitter-text';
|
|
11
|
+
import { getServerBridge } from './server-bridge.js';
|
|
11
12
|
const { parseTweet } = twitter;
|
|
12
|
-
|
|
13
|
+
/** X Articles API base. Two-step: draft, then publish. */
|
|
14
|
+
const X_API_BASE = 'https://api.x.com/2';
|
|
15
|
+
/** Build the OAuth1 signer from plugin config / env. Returns null when any of
|
|
16
|
+
* the four credentials is missing. Shared by the SDK client and the raw
|
|
17
|
+
* Articles calls (which the SDK doesn't model). */
|
|
18
|
+
function createOAuth1(config) {
|
|
13
19
|
const apiKey = config['api-key'] || process.env.X_API_KEY || '';
|
|
14
20
|
const apiSecret = config['api-secret'] || process.env.X_API_SECRET || '';
|
|
15
21
|
const accessToken = config['access-token'] || process.env.X_ACCESS_TOKEN || '';
|
|
16
22
|
const accessTokenSecret = config['access-token-secret'] || process.env.X_ACCESS_TOKEN_SECRET || '';
|
|
17
23
|
if (!apiKey || !apiSecret || !accessToken || !accessTokenSecret)
|
|
18
24
|
return null;
|
|
19
|
-
|
|
25
|
+
return new OAuth1({
|
|
20
26
|
apiKey,
|
|
21
27
|
apiSecret,
|
|
22
28
|
callback: 'oob',
|
|
23
29
|
accessToken,
|
|
24
30
|
accessTokenSecret,
|
|
25
31
|
});
|
|
32
|
+
}
|
|
33
|
+
function createXClient(config) {
|
|
34
|
+
const oauth1 = createOAuth1(config);
|
|
35
|
+
if (!oauth1)
|
|
36
|
+
return null;
|
|
26
37
|
return new Client({ oauth1 });
|
|
27
38
|
}
|
|
39
|
+
/** Make an OAuth1-signed JSON request to the X API. The SDK has no Articles
|
|
40
|
+
* resource, so we sign + fetch directly. `buildRequestHeader` correctly
|
|
41
|
+
* excludes a JSON body from the OAuth1 signature base string. */
|
|
42
|
+
async function xApiFetch(oauth1, method, path, body) {
|
|
43
|
+
const url = `${X_API_BASE}${path}`;
|
|
44
|
+
const bodyStr = body ? JSON.stringify(body) : '';
|
|
45
|
+
const authHeader = await oauth1.buildRequestHeader(method, url, bodyStr);
|
|
46
|
+
const headers = { Authorization: authHeader };
|
|
47
|
+
if (body)
|
|
48
|
+
headers['Content-Type'] = 'application/json';
|
|
49
|
+
const res = await fetch(url, { method, headers, body: body ? bodyStr : undefined });
|
|
50
|
+
const data = await res.json().catch(() => ({}));
|
|
51
|
+
return { ok: res.ok, status: res.status, data };
|
|
52
|
+
}
|
|
28
53
|
const plugin = {
|
|
29
54
|
name: '@openwriter/plugin-x-api',
|
|
30
55
|
version: '0.1.0',
|
|
@@ -235,6 +260,109 @@ const plugin = {
|
|
|
235
260
|
res.status(500).json({ success: false, error: err.message });
|
|
236
261
|
}
|
|
237
262
|
});
|
|
263
|
+
// POST /api/x/post-article — publish the active document as a native X
|
|
264
|
+
// Article. Two-step X flow: draft, then publish. Reads the active server
|
|
265
|
+
// doc (same source the managed/publish path reads) and converts it to X's
|
|
266
|
+
// DraftJS content_state via the shared converter.
|
|
267
|
+
ctx.app.post('/api/x/post-article', async (_req, res) => {
|
|
268
|
+
try {
|
|
269
|
+
const oauth1 = createOAuth1(ctx.config);
|
|
270
|
+
if (!oauth1) {
|
|
271
|
+
res.status(400).json({ success: false, error: 'X API credentials not configured' });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const bridge = await getServerBridge();
|
|
275
|
+
const doc = bridge.getDocument();
|
|
276
|
+
const title = (bridge.getTitle() || '').trim();
|
|
277
|
+
const metadata = bridge.getMetadata() || {};
|
|
278
|
+
if (!title || title === 'Untitled') {
|
|
279
|
+
res.status(400).json({ success: false, error: 'Article needs a title before posting.' });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const contentState = bridge.tiptapToDraftjs(doc);
|
|
283
|
+
if (!contentState.blocks.some((b) => (b.text || '').trim().length > 0)) {
|
|
284
|
+
res.status(400).json({ success: false, error: 'Article body is empty.' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// Optional cover image — upload to X and attach as cover_media.
|
|
288
|
+
let coverMedia;
|
|
289
|
+
const coverSrc = metadata?.articleContext?.coverImage;
|
|
290
|
+
if (coverSrc && typeof coverSrc === 'string' && /^\/_images\/[^/\\]+$/.test(coverSrc)) {
|
|
291
|
+
const client = createXClient(ctx.config);
|
|
292
|
+
if (client) {
|
|
293
|
+
const mediaId = await uploadCoverMedia(client, ctx.dataDir, coverSrc);
|
|
294
|
+
if (mediaId)
|
|
295
|
+
coverMedia = { media_category: 'TWEET_IMAGE', media_id: mediaId };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Step 1 — create the draft.
|
|
299
|
+
const draftBody = { title, content_state: contentState };
|
|
300
|
+
if (coverMedia)
|
|
301
|
+
draftBody.cover_media = coverMedia;
|
|
302
|
+
const draft = await xApiFetch(oauth1, 'POST', '/articles/draft', draftBody);
|
|
303
|
+
if (!draft.ok) {
|
|
304
|
+
const detail = draft.data?.detail || draft.data?.title || JSON.stringify(draft.data);
|
|
305
|
+
console.error('[X Plugin] Article draft failed:', draft.status, detail);
|
|
306
|
+
res.status(draft.status || 500).json({ success: false, error: `Draft failed: ${detail}` });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const articleId = draft.data?.data?.id;
|
|
310
|
+
if (!articleId) {
|
|
311
|
+
res.status(500).json({ success: false, error: 'Draft created but no article id returned.' });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// Step 2 — publish the draft (makes it public). Surface any error
|
|
315
|
+
// verbatim — a draft is private until this succeeds.
|
|
316
|
+
const published = await xApiFetch(oauth1, 'POST', `/articles/${articleId}/publish`);
|
|
317
|
+
if (!published.ok) {
|
|
318
|
+
const detail = published.data?.detail || published.data?.title || JSON.stringify(published.data);
|
|
319
|
+
console.error('[X Plugin] Article publish failed:', published.status, detail);
|
|
320
|
+
res.status(published.status || 500).json({ success: false, articleId, error: `Publish failed: ${detail}` });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const postId = published.data?.data?.post_id;
|
|
324
|
+
const articleUrl = postId ? `https://x.com/i/status/${postId}` : undefined;
|
|
325
|
+
console.log(`[X Plugin] Article published: ${articleId} -> ${articleUrl}`);
|
|
326
|
+
res.json({ success: true, articleId, postId, articleUrl });
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
const detail = err.data ? JSON.stringify(err.data) : err.message;
|
|
330
|
+
console.error('[X Plugin] Post article failed:', detail);
|
|
331
|
+
res.status(500).json({ success: false, error: detail });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
238
334
|
},
|
|
239
335
|
};
|
|
336
|
+
/** Upload an article cover image to X, returning its media_id. Mirrors the
|
|
337
|
+
* /api/x/upload-media compression rules (>3MB or PNG -> JPEG). Returns null on
|
|
338
|
+
* any failure — the article still posts, just without a cover. */
|
|
339
|
+
async function uploadCoverMedia(client, dataDir, src) {
|
|
340
|
+
try {
|
|
341
|
+
const filename = src.replace('/_images/', '');
|
|
342
|
+
const filePath = join(dataDir, '_images', filename);
|
|
343
|
+
if (!existsSync(filePath))
|
|
344
|
+
return null;
|
|
345
|
+
const ext = extname(filename).toLowerCase();
|
|
346
|
+
const mimeMap = {
|
|
347
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
348
|
+
'.png': 'image/png', '.webp': 'image/webp',
|
|
349
|
+
'.gif': 'image/jpeg', '.bmp': 'image/bmp',
|
|
350
|
+
'.tiff': 'image/tiff', '.tif': 'image/tiff',
|
|
351
|
+
};
|
|
352
|
+
let fileBuffer = readFileSync(filePath);
|
|
353
|
+
let uploadType = mimeMap[ext] || 'image/jpeg';
|
|
354
|
+
if (fileBuffer.length > 3 * 1024 * 1024 || ext === '.png') {
|
|
355
|
+
fileBuffer = Buffer.from(await sharp(fileBuffer).jpeg({ quality: 85 }).toBuffer());
|
|
356
|
+
uploadType = 'image/jpeg';
|
|
357
|
+
}
|
|
358
|
+
const uploadResult = await client.media.upload({
|
|
359
|
+
body: { media: fileBuffer.toString('base64'), mediaCategory: 'tweet_image', mediaType: uploadType },
|
|
360
|
+
});
|
|
361
|
+
return uploadResult?.data?.id || uploadResult?.media_id_string || null;
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
console.error('[X Plugin] Cover upload failed:', err.message);
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
240
368
|
export default plugin;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin runtime bridge to the OpenWriter server modules this plugin needs for
|
|
3
|
+
* article publishing — the active document, its title/metadata, the data dir,
|
|
4
|
+
* and the shared TipTap -> DraftJS converter.
|
|
5
|
+
*
|
|
6
|
+
* The converter (server/tiptap-draftjs.ts) is shared with plugins/publish so
|
|
7
|
+
* both posting paths produce identical X content_state. We import the compiled
|
|
8
|
+
* server module at runtime rather than vendoring it, resolving both the npm
|
|
9
|
+
* package layout and the monorepo layout — the same dual-path trick the publish
|
|
10
|
+
* plugin's helpers use.
|
|
11
|
+
*/
|
|
12
|
+
export interface ServerBridge {
|
|
13
|
+
getDocument: () => any;
|
|
14
|
+
getTitle: () => string;
|
|
15
|
+
getMetadata: () => Record<string, any>;
|
|
16
|
+
getDataDir: () => string;
|
|
17
|
+
tiptapToDraftjs: (doc: any) => {
|
|
18
|
+
blocks: any[];
|
|
19
|
+
entities: any[];
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export declare function getServerBridge(): Promise<ServerBridge>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin runtime bridge to the OpenWriter server modules this plugin needs for
|
|
3
|
+
* article publishing — the active document, its title/metadata, the data dir,
|
|
4
|
+
* and the shared TipTap -> DraftJS converter.
|
|
5
|
+
*
|
|
6
|
+
* The converter (server/tiptap-draftjs.ts) is shared with plugins/publish so
|
|
7
|
+
* both posting paths produce identical X content_state. We import the compiled
|
|
8
|
+
* server module at runtime rather than vendoring it, resolving both the npm
|
|
9
|
+
* package layout and the monorepo layout — the same dual-path trick the publish
|
|
10
|
+
* plugin's helpers use.
|
|
11
|
+
*/
|
|
12
|
+
// npm package: dist/plugins/x-api/dist/server-bridge.js -> dist/server/
|
|
13
|
+
// monorepo: plugins/x-api/dist/server-bridge.js -> packages/openwriter/dist/server/
|
|
14
|
+
const npmBase = new URL('../../../server/', import.meta.url).href;
|
|
15
|
+
const monoBase = new URL('../../../packages/openwriter/dist/server/', import.meta.url).href;
|
|
16
|
+
let cached = null;
|
|
17
|
+
async function tryImport(base) {
|
|
18
|
+
const [state, helpers, draftjs] = await Promise.all([
|
|
19
|
+
import(base + 'state.js'),
|
|
20
|
+
import(base + 'helpers.js'),
|
|
21
|
+
import(base + 'tiptap-draftjs.js'),
|
|
22
|
+
]);
|
|
23
|
+
return { state, helpers, draftjs };
|
|
24
|
+
}
|
|
25
|
+
export async function getServerBridge() {
|
|
26
|
+
if (cached)
|
|
27
|
+
return cached;
|
|
28
|
+
let state, helpers, draftjs;
|
|
29
|
+
try {
|
|
30
|
+
({ state, helpers, draftjs } = await tryImport(npmBase));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
({ state, helpers, draftjs } = await tryImport(monoBase));
|
|
34
|
+
}
|
|
35
|
+
cached = {
|
|
36
|
+
getDocument: state.getDocument,
|
|
37
|
+
getTitle: state.getTitle,
|
|
38
|
+
getMetadata: state.getMetadata,
|
|
39
|
+
getDataDir: helpers.getDataDir,
|
|
40
|
+
tiptapToDraftjs: draftjs.tiptapToDraftjs,
|
|
41
|
+
};
|
|
42
|
+
return cached;
|
|
43
|
+
}
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
* Local app no longer stores connections.
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from 'express';
|
|
6
|
+
import { readFileSync, existsSync } from 'fs';
|
|
7
|
+
import { join, extname } from 'path';
|
|
6
8
|
import { platformFetch, isAuthenticated } from './connections.js';
|
|
9
|
+
import { getDocument, getTitle, getMetadata } from './state.js';
|
|
10
|
+
import { getDataDir } from './helpers.js';
|
|
11
|
+
import { tiptapToDraftjs } from './tiptap-draftjs.js';
|
|
7
12
|
export function createConnectionRouter() {
|
|
8
13
|
const router = Router();
|
|
9
14
|
// Unified connections list (OAuth + newsletter domains)
|
|
@@ -376,5 +381,66 @@ export function createConnectionRouter() {
|
|
|
376
381
|
res.status(500).json({ success: false, error: err.message });
|
|
377
382
|
}
|
|
378
383
|
});
|
|
384
|
+
// POST /api/x/post-article — publish the active doc as a native X Article.
|
|
385
|
+
// Platform connection first (managed path), then fall through to the x-api
|
|
386
|
+
// plugin (direct OAuth1). Mirrors the /api/x/post routing. The article body
|
|
387
|
+
// is read from the active server doc and converted to X content_state here;
|
|
388
|
+
// the cover image (articleContext.coverImage) is uploaded via the platform.
|
|
389
|
+
router.post('/api/x/post-article', async (req, res, next) => {
|
|
390
|
+
const conn = await getFirstXConnection();
|
|
391
|
+
if (!conn) {
|
|
392
|
+
next();
|
|
393
|
+
return;
|
|
394
|
+
} // No platform connection → direct plugin path
|
|
395
|
+
try {
|
|
396
|
+
const title = (getTitle() || '').trim();
|
|
397
|
+
if (!title || title === 'Untitled') {
|
|
398
|
+
res.status(400).json({ success: false, error: 'Article needs a title before posting.' });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const contentState = tiptapToDraftjs(getDocument());
|
|
402
|
+
if (!contentState.blocks.some((b) => (b.text || '').trim().length > 0)) {
|
|
403
|
+
res.status(400).json({ success: false, error: 'Article body is empty.' });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Optional cover — upload through the platform, attach by media id.
|
|
407
|
+
let coverMediaId;
|
|
408
|
+
const coverSrc = getMetadata()?.articleContext?.coverImage;
|
|
409
|
+
if (coverSrc && typeof coverSrc === 'string' && /^\/_images\/[^/\\]+$/.test(coverSrc)) {
|
|
410
|
+
const filename = coverSrc.replace('/_images/', '');
|
|
411
|
+
const filePath = join(getDataDir(), '_images', filename);
|
|
412
|
+
if (existsSync(filePath)) {
|
|
413
|
+
const ext = extname(filename).toLowerCase();
|
|
414
|
+
const mimeMap = {
|
|
415
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
416
|
+
'.png': 'image/png', '.webp': 'image/webp',
|
|
417
|
+
'.gif': 'image/gif', '.bmp': 'image/bmp',
|
|
418
|
+
};
|
|
419
|
+
const uploadRes = await platformFetch(`/connections/${conn.id}/upload-media`, {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
body: JSON.stringify({ media_base64: readFileSync(filePath).toString('base64'), media_type: mimeMap[ext] || 'image/jpeg' }),
|
|
422
|
+
});
|
|
423
|
+
if (uploadRes.ok) {
|
|
424
|
+
const data = await uploadRes.json();
|
|
425
|
+
if (data.mediaId)
|
|
426
|
+
coverMediaId = data.mediaId;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const upstream = await platformFetch(`/connections/${conn.id}/post-article`, {
|
|
431
|
+
method: 'POST',
|
|
432
|
+
body: JSON.stringify({ title, content_state: contentState, cover_media_id: coverMediaId }),
|
|
433
|
+
});
|
|
434
|
+
const data = await upstream.json();
|
|
435
|
+
if (!upstream.ok || !data.success) {
|
|
436
|
+
res.status(upstream.status || 500).json({ success: false, error: data.error || 'Article publish failed' });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
res.json({ success: true, articleId: data.articleId, postId: data.postId, articleUrl: data.post_url });
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
res.status(500).json({ success: false, error: err.message });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
379
445
|
return router;
|
|
380
446
|
}
|