@bloklabs/server 1.11.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/README.md +65 -0
- package/bin/blok-server.mjs +291 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @bloklabs/server
|
|
2
|
+
|
|
3
|
+
A small Go sidecar for [Blok](https://blokeditor.com). It answers the two requests a rich-text editor cannot make from the browser — storing an uploaded file, and reading the title/description/image of a pasted link — so you do not write a backend for either.
|
|
4
|
+
|
|
5
|
+
It stores no documents. There is no database, and nothing here is a hosted service you depend on: you run it.
|
|
6
|
+
|
|
7
|
+
## Run it
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @bloklabs/server --listen 127.0.0.1:4000
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The npm package is a wrapper. On first run it downloads the binary for your platform from the matching GitHub release, checks it against the release's `checksums.txt`, and caches it. If your network blocks that download, the container image is the same build:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
docker run -p 4000:4000 ghcr.io/jackuait/blok-server --listen 0.0.0.0:4000
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Point the editor at it
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { Blok } from '@bloklabs/core';
|
|
23
|
+
|
|
24
|
+
new Blok({
|
|
25
|
+
holder: 'editor',
|
|
26
|
+
uploader: {
|
|
27
|
+
async uploadByFile(file) {
|
|
28
|
+
const body = new FormData();
|
|
29
|
+
|
|
30
|
+
body.append('file', file);
|
|
31
|
+
|
|
32
|
+
const res = await fetch('http://127.0.0.1:4000/upload', { method: 'POST', body });
|
|
33
|
+
|
|
34
|
+
return res.json();
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The response shapes match what Blok's own parsers already expect, so nothing on the editor side needs configuring beyond the URL.
|
|
41
|
+
|
|
42
|
+
## Routes
|
|
43
|
+
|
|
44
|
+
| Route | What it does |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `GET /health` | Liveness plus the running version |
|
|
47
|
+
| `GET /unfurl?url=…` | Title, description and image for a pasted link |
|
|
48
|
+
| `POST /upload` | Stores an uploaded file, returns where it landed |
|
|
49
|
+
| `POST /upload-by-url` | Fetches a remote URL server-side and re-hosts it |
|
|
50
|
+
|
|
51
|
+
Both routes that fetch a URL the browser supplied go through one SSRF-guarded HTTP client, so a pasted `http://169.254.169.254/…` cannot reach your cloud metadata endpoint.
|
|
52
|
+
|
|
53
|
+
## Access modes
|
|
54
|
+
|
|
55
|
+
`--auth` picks who may call the service, and the process refuses to start on a combination that would leave it open:
|
|
56
|
+
|
|
57
|
+
- `none` — no check, and therefore only allowed to bind loopback.
|
|
58
|
+
- `proxy` — trusts every caller, for when something in front of it already authenticated them; loopback only for the same reason.
|
|
59
|
+
- `ticket` — each request carries a short-lived pass your app signs with the shared secret. The only mode allowed to bind a public address.
|
|
60
|
+
|
|
61
|
+
Storage is a local directory by default (`--storage-dir`), or any S3-compatible bucket via `--s3-bucket` and friends. Credentials are read from `BLOK_S3_ACCESS_KEY` / `BLOK_S3_SECRET_KEY` — never from flags, which land in process listings.
|
|
62
|
+
|
|
63
|
+
## Docs
|
|
64
|
+
|
|
65
|
+
Full flag reference and deployment notes: [https://blokeditor.com/docs](https://blokeditor.com/docs)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* npm wrapper for the blok-server Go binary.
|
|
4
|
+
*
|
|
5
|
+
* The package ships no binary. On first run it resolves the archive goreleaser
|
|
6
|
+
* published for this platform on the GitHub release matching this package's
|
|
7
|
+
* version, verifies it against that release's checksums.txt, unpacks it into a
|
|
8
|
+
* cache directory, and execs it. Later runs go straight to the cached binary.
|
|
9
|
+
*
|
|
10
|
+
* The version is the family version, not a server version: every release moves
|
|
11
|
+
* it whether or not the Go sources changed, which is why .github/workflows/
|
|
12
|
+
* release-server.yml publishes archives on EVERY tag and skips only the image.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { homedir, tmpdir } from 'node:os';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const REPO = 'https://github.com/JackUait/blok';
|
|
22
|
+
const IMAGE = 'ghcr.io/jackuait/blok-server';
|
|
23
|
+
const CHECKSUMS_FILE = 'checksums.txt';
|
|
24
|
+
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
/** Mirrors the goos/goarch matrix in .goreleaser.yaml. */
|
|
28
|
+
const PLATFORMS = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
|
|
29
|
+
const ARCHITECTURES = { x64: 'amd64', arm64: 'arm64' };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} platform - process.platform
|
|
33
|
+
* @param {string} arch - process.arch
|
|
34
|
+
* @returns {{ os: string, arch: string, archive: string, binary: string } | null}
|
|
35
|
+
*/
|
|
36
|
+
export function resolveTarget(platform, arch) {
|
|
37
|
+
const os = PLATFORMS[platform];
|
|
38
|
+
const goarch = ARCHITECTURES[arch];
|
|
39
|
+
|
|
40
|
+
if (os === undefined || goarch === undefined) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Must match archives.name_template + format_overrides in .goreleaser.yaml.
|
|
45
|
+
return {
|
|
46
|
+
os,
|
|
47
|
+
arch: goarch,
|
|
48
|
+
archive: `blok-server_${os}_${goarch}.${os === 'windows' ? 'zip' : 'tar.gz'}`,
|
|
49
|
+
binary: os === 'windows' ? 'blok-server.exe' : 'blok-server',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every platform this wrapper can serve, for the error message. */
|
|
54
|
+
export function supportedTargets() {
|
|
55
|
+
return Object.keys(PLATFORMS)
|
|
56
|
+
.flatMap((platform) => Object.keys(ARCHITECTURES).map((arch) => `${platform}/${arch}`));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read one asset's expected digest out of a goreleaser checksums file, whose
|
|
61
|
+
* lines are `<sha256><two spaces><filename>`.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} contents - checksums.txt body
|
|
64
|
+
* @param {string} assetName - the archive filename to look up
|
|
65
|
+
* @returns {string | null} lowercase hex digest, or null when unlisted
|
|
66
|
+
*/
|
|
67
|
+
export function checksumFor(contents, assetName) {
|
|
68
|
+
for (const line of contents.split('\n')) {
|
|
69
|
+
const [digest, name] = line.trim().split(/\s+/);
|
|
70
|
+
|
|
71
|
+
if (name === assetName && /^[0-9a-f]{64}$/i.test(digest ?? '')) {
|
|
72
|
+
return digest.toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Where the binary is cached. The env override exists for locked-down machines
|
|
81
|
+
* where the home directory is read-only but some other path is not.
|
|
82
|
+
*
|
|
83
|
+
* @param {NodeJS.ProcessEnv} env
|
|
84
|
+
* @param {string} platform - process.platform
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function cacheRoot(env, platform) {
|
|
88
|
+
if (env.BLOK_SERVER_CACHE_DIR) {
|
|
89
|
+
return env.BLOK_SERVER_CACHE_DIR;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (platform === 'win32') {
|
|
93
|
+
return join(env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'blok-server', 'Cache');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (platform === 'darwin') {
|
|
97
|
+
return join(homedir(), 'Library', 'Caches', 'blok-server');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'blok-server');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* First of the candidate roots that actually accepts a write. A cache that
|
|
105
|
+
* cannot be written is not an error yet — the temp directory is a fine second
|
|
106
|
+
* home for a single run — so this only throws when nothing is writable.
|
|
107
|
+
*
|
|
108
|
+
* @param {string[]} candidates
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
function firstWritable(candidates) {
|
|
112
|
+
const failures = [];
|
|
113
|
+
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(candidate, { recursive: true });
|
|
117
|
+
|
|
118
|
+
const probe = join(candidate, `.write-probe-${process.pid}`);
|
|
119
|
+
|
|
120
|
+
writeFileSync(probe, '');
|
|
121
|
+
rmSync(probe, { force: true });
|
|
122
|
+
|
|
123
|
+
return candidate;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
failures.push(`${candidate} (${error.message})`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw new Error(`no writable cache directory: ${failures.join('; ')}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} url
|
|
134
|
+
* @returns {Promise<Buffer>}
|
|
135
|
+
*/
|
|
136
|
+
async function download(url) {
|
|
137
|
+
const response = await fetch(url);
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`GET ${url} → ${response.status} ${response.statusText}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Buffer.from(await response.arrayBuffer());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Download, verify and unpack the archive, leaving the binary at `destination`.
|
|
148
|
+
*
|
|
149
|
+
* Everything happens inside a scratch directory next to the destination, and the
|
|
150
|
+
* finished binary is moved into place with a single rename: a run interrupted at
|
|
151
|
+
* any point leaves a scratch directory, never a truncated binary at the path the
|
|
152
|
+
* next run would execute.
|
|
153
|
+
*
|
|
154
|
+
* @param {{ version: string, target: ReturnType<typeof resolveTarget>, destination: string }} opts
|
|
155
|
+
*/
|
|
156
|
+
async function fetchBinary({ version, target, destination }) {
|
|
157
|
+
const base = `${REPO}/releases/download/v${version}`;
|
|
158
|
+
// A 404 here means an unsigned download, so it aborts the run rather than
|
|
159
|
+
// falling through to "verification unavailable, running it anyway".
|
|
160
|
+
const checksums = (await download(`${base}/${CHECKSUMS_FILE}`)).toString('utf-8');
|
|
161
|
+
const expected = checksumFor(checksums, target.archive);
|
|
162
|
+
|
|
163
|
+
if (expected === null) {
|
|
164
|
+
throw new Error(`${CHECKSUMS_FILE} for v${version} does not list ${target.archive}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const scratch = mkdtempSync(join(dirname(destination), '.download-'));
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const archive = await download(`${base}/${target.archive}`);
|
|
171
|
+
const actual = createHash('sha256').update(archive).digest('hex');
|
|
172
|
+
|
|
173
|
+
if (actual !== expected) {
|
|
174
|
+
throw new Error(`checksum mismatch for ${target.archive}: expected ${expected}, got ${actual}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const archivePath = join(scratch, target.archive);
|
|
178
|
+
|
|
179
|
+
writeFileSync(archivePath, archive);
|
|
180
|
+
|
|
181
|
+
// -xf, never -xzf: bsdtar (macOS, and tar.exe on Windows) detects gzip and
|
|
182
|
+
// zip on its own, and -z would reject the Windows zip outright.
|
|
183
|
+
const extracted = spawnSync('tar', ['-xf', archivePath, '-C', scratch], { stdio: 'inherit' });
|
|
184
|
+
|
|
185
|
+
if (extracted.error) {
|
|
186
|
+
throw new Error(`could not run tar: ${extracted.error.message}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (extracted.status !== 0) {
|
|
190
|
+
throw new Error(`tar exited with ${extracted.status} unpacking ${target.archive}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const unpacked = join(scratch, target.binary);
|
|
194
|
+
|
|
195
|
+
if (!existsSync(unpacked)) {
|
|
196
|
+
throw new Error(`${target.archive} did not contain ${target.binary}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
chmodSync(unpacked, 0o755);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
renameSync(unpacked, destination);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// A concurrent run may have won the race; its binary passed the same
|
|
205
|
+
// checksum, so it is just as good. Windows rejects rename-over-existing.
|
|
206
|
+
if (!existsSync(destination)) {
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {string} version
|
|
217
|
+
* @param {string} reason
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
export function fallbackMessage(version, reason) {
|
|
221
|
+
return [
|
|
222
|
+
`blok-server: ${reason}`,
|
|
223
|
+
'',
|
|
224
|
+
'The same build is published as a container image — run that instead:',
|
|
225
|
+
// The service defaults to loopback, which nothing outside the container can
|
|
226
|
+
// reach, so the published port needs an explicit bind address to match.
|
|
227
|
+
` docker run -p 4000:4000 ${IMAGE}:${version} --listen 0.0.0.0:4000`,
|
|
228
|
+
'',
|
|
229
|
+
`Or download it by hand: ${REPO}/releases/tag/v${version}`,
|
|
230
|
+
].join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function main() {
|
|
234
|
+
const { version } = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf-8'));
|
|
235
|
+
const target = resolveTarget(process.platform, process.arch);
|
|
236
|
+
|
|
237
|
+
if (target === null) {
|
|
238
|
+
console.error(fallbackMessage(
|
|
239
|
+
version,
|
|
240
|
+
`no binary is published for ${process.platform}/${process.arch} (published: ${supportedTargets().join(', ')})`
|
|
241
|
+
));
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let binary;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const root = firstWritable([
|
|
249
|
+
join(cacheRoot(process.env, process.platform), version),
|
|
250
|
+
join(tmpdir(), 'blok-server', version),
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
binary = join(root, target.binary);
|
|
254
|
+
|
|
255
|
+
if (!existsSync(binary)) {
|
|
256
|
+
await fetchBinary({ version, target, destination: binary });
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error(fallbackMessage(version, error.message));
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
|
|
264
|
+
|
|
265
|
+
if (result.error) {
|
|
266
|
+
console.error(fallbackMessage(version, `could not start ${binary}: ${result.error.message}`));
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
process.exit(result.status ?? 1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Importing this file (the unit tests do) must not start a download.
|
|
274
|
+
//
|
|
275
|
+
// argv[1] MUST be realpath'd: npm installs a bin as node_modules/.bin/blok-server,
|
|
276
|
+
// a symlink to this file, and Node resolves import.meta.url through that symlink
|
|
277
|
+
// while leaving argv[1] as the link. Comparing them raw makes every `npx` run
|
|
278
|
+
// exit 0 having done nothing at all.
|
|
279
|
+
let isDirectRun = false;
|
|
280
|
+
|
|
281
|
+
if (process.argv[1] !== undefined) {
|
|
282
|
+
try {
|
|
283
|
+
isDirectRun = import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
284
|
+
} catch {
|
|
285
|
+
isDirectRun = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (isDirectRun) {
|
|
290
|
+
await main();
|
|
291
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bloklabs/server",
|
|
3
|
+
"version": "1.11.0",
|
|
4
|
+
"description": "Blok backend service — a small Go sidecar that handles file uploads and link previews, so you write no backend code for either.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "JackUait",
|
|
7
|
+
"homepage": "https://blokeditor.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/JackUait/blok.git",
|
|
11
|
+
"directory": "packages/server"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/JackUait/blok/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"blok",
|
|
18
|
+
"block-editor",
|
|
19
|
+
"rich-text-editor",
|
|
20
|
+
"file-upload",
|
|
21
|
+
"uploads",
|
|
22
|
+
"link-preview",
|
|
23
|
+
"unfurl",
|
|
24
|
+
"open-graph",
|
|
25
|
+
"server",
|
|
26
|
+
"sidecar",
|
|
27
|
+
"ssrf"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"blok-server": "bin/blok-server.mjs"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|