underpost 3.0.0 → 3.0.2
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/.env.development +1 -2
- package/.env.test +1 -2
- package/.github/workflows/gitlab.ci.yml +20 -0
- package/CHANGELOG.md +105 -1
- package/CLI-HELP.md +2 -4
- package/README.md +2 -2
- package/bin/build.js +6 -3
- package/bin/deploy.js +18 -26
- package/bin/file.js +31 -2
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
- package/manifests/ipfs/configmap.yaml +7 -0
- package/package.json +5 -1
- package/src/api/file/file.controller.js +3 -13
- package/src/api/file/file.ref.json +0 -21
- package/src/api/user/user.router.js +0 -47
- package/src/cli/cluster.js +30 -38
- package/src/cli/index.js +0 -1
- package/src/cli/lxd.js +1 -1
- package/src/cli/run.js +17 -2
- package/src/client/components/core/Docs.js +92 -6
- package/src/client/components/core/LoadingAnimation.js +2 -3
- package/src/client/components/core/Modal.js +1 -1
- package/src/client/components/core/VanillaJs.js +36 -0
- package/src/index.js +1 -1
- package/src/runtime/express/Express.js +4 -3
- package/src/server/auth.js +18 -18
- package/src/server/client-build-docs.js +152 -34
- package/src/server/ipfs-client.js +433 -0
- package/bin/ssl.js +0 -63
|
@@ -10,6 +10,7 @@ import fs from 'fs-extra';
|
|
|
10
10
|
import { shellExec } from './process.js';
|
|
11
11
|
import { loggerFactory } from './logger.js';
|
|
12
12
|
import { JSONweb } from './client-formatted.js';
|
|
13
|
+
import { ssrFactory } from './ssr.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Builds API documentation using Swagger
|
|
@@ -62,53 +63,91 @@ const buildApiDocs = async ({
|
|
|
62
63
|
components: {
|
|
63
64
|
schemas: {
|
|
64
65
|
userRequest: {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
type: 'object',
|
|
67
|
+
required: ['username', 'password', 'email'],
|
|
68
|
+
properties: {
|
|
69
|
+
username: { type: 'string', example: 'user123' },
|
|
70
|
+
password: { type: 'string', example: 'Password123!' },
|
|
71
|
+
email: { type: 'string', format: 'email', example: 'user@example.com' },
|
|
72
|
+
},
|
|
68
73
|
},
|
|
69
74
|
userResponse: {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
status: { type: 'string', example: 'success' },
|
|
78
|
+
data: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
token: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7Il9pZCI6IjY2YzM3N2Y1N2Y5OWU1OTY5YjgxZG...',
|
|
84
|
+
},
|
|
85
|
+
user: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
_id: { type: 'string', example: '66c377f57f99e5969b81de89' },
|
|
89
|
+
email: { type: 'string', format: 'email', example: 'user@example.com' },
|
|
90
|
+
emailConfirmed: { type: 'boolean', example: false },
|
|
91
|
+
username: { type: 'string', example: 'user123' },
|
|
92
|
+
role: { type: 'string', example: 'user' },
|
|
93
|
+
profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
80
97
|
},
|
|
81
98
|
},
|
|
82
99
|
},
|
|
83
100
|
userUpdateResponse: {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
status: { type: 'string', example: 'success' },
|
|
104
|
+
data: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
_id: { type: 'string', example: '66c377f57f99e5969b81de89' },
|
|
108
|
+
email: { type: 'string', format: 'email', example: 'user@example.com' },
|
|
109
|
+
emailConfirmed: { type: 'boolean', example: false },
|
|
110
|
+
username: { type: 'string', example: 'user123222' },
|
|
111
|
+
role: { type: 'string', example: 'user' },
|
|
112
|
+
profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
92
115
|
},
|
|
93
116
|
},
|
|
94
117
|
userGetResponse: {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
status: { type: 'string', example: 'success' },
|
|
121
|
+
data: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
_id: { type: 'string', example: '66c377f57f99e5969b81de89' },
|
|
125
|
+
email: { type: 'string', format: 'email', example: 'user@example.com' },
|
|
126
|
+
emailConfirmed: { type: 'boolean', example: false },
|
|
127
|
+
username: { type: 'string', example: 'user123222' },
|
|
128
|
+
role: { type: 'string', example: 'user' },
|
|
129
|
+
profileImageId: { type: 'string', example: '66c377f57f99e5969b81de87' },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
103
132
|
},
|
|
104
133
|
},
|
|
105
134
|
userLogInRequest: {
|
|
106
|
-
|
|
107
|
-
|
|
135
|
+
type: 'object',
|
|
136
|
+
required: ['email', 'password'],
|
|
137
|
+
properties: {
|
|
138
|
+
email: { type: 'string', format: 'email', example: 'user@example.com' },
|
|
139
|
+
password: { type: 'string', example: 'Password123!' },
|
|
140
|
+
},
|
|
108
141
|
},
|
|
109
142
|
userBadRequestResponse: {
|
|
110
|
-
|
|
111
|
-
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
status: { type: 'string', example: 'error' },
|
|
146
|
+
message: {
|
|
147
|
+
type: 'string',
|
|
148
|
+
example: 'Bad request. Please check your inputs, and try again',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
112
151
|
},
|
|
113
152
|
},
|
|
114
153
|
securitySchemes: {
|
|
@@ -120,6 +159,44 @@ const buildApiDocs = async ({
|
|
|
120
159
|
},
|
|
121
160
|
};
|
|
122
161
|
|
|
162
|
+
/**
|
|
163
|
+
* swagger-autogen has no requestBody annotation support — it only handles
|
|
164
|
+
* #swagger.parameters, responses, security, etc. We define the requestBody
|
|
165
|
+
* objects here and inject them into the generated JSON as a post-processing step.
|
|
166
|
+
*
|
|
167
|
+
* Each key is an "<method> <path>" pair matching the generated paths object.
|
|
168
|
+
* The value is a valid OAS 3.0 requestBody object.
|
|
169
|
+
*/
|
|
170
|
+
const requestBodies = {
|
|
171
|
+
'post /user': {
|
|
172
|
+
description: 'User registration data',
|
|
173
|
+
required: true,
|
|
174
|
+
content: {
|
|
175
|
+
'application/json': {
|
|
176
|
+
schema: { $ref: '#/components/schemas/userRequest' },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
'post /user/auth': {
|
|
181
|
+
description: 'User login credentials',
|
|
182
|
+
required: true,
|
|
183
|
+
content: {
|
|
184
|
+
'application/json': {
|
|
185
|
+
schema: { $ref: '#/components/schemas/userLogInRequest' },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
'put /user/{id}': {
|
|
190
|
+
description: 'User fields to update',
|
|
191
|
+
required: true,
|
|
192
|
+
content: {
|
|
193
|
+
'application/json': {
|
|
194
|
+
schema: { $ref: '#/components/schemas/userRequest' },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
123
200
|
logger.warn('build swagger api docs', doc.info);
|
|
124
201
|
|
|
125
202
|
// swagger-autogen@2.9.2 bug: getProducesTag, getConsumesTag, getResponsesTag missing __¬¬¬__ decode before eval
|
|
@@ -148,6 +225,33 @@ const buildApiDocs = async ({
|
|
|
148
225
|
}
|
|
149
226
|
|
|
150
227
|
await swaggerAutoGen({ openapi: '3.0.0' })(outputFile, routes, doc);
|
|
228
|
+
|
|
229
|
+
// Post-process: inject requestBody into operations — swagger-autogen silently
|
|
230
|
+
// ignores #swagger.requestBody annotations and has no internal OAS-3 body support.
|
|
231
|
+
if (fs.existsSync(outputFile)) {
|
|
232
|
+
const swaggerJson = JSON.parse(fs.readFileSync(outputFile, 'utf8'));
|
|
233
|
+
let patched = false;
|
|
234
|
+
|
|
235
|
+
for (const [key, requestBody] of Object.entries(requestBodies)) {
|
|
236
|
+
const [method, ...pathParts] = key.split(' ');
|
|
237
|
+
const opPath = pathParts.join(' ');
|
|
238
|
+
if (swaggerJson.paths?.[opPath]?.[method]) {
|
|
239
|
+
swaggerJson.paths[opPath][method].requestBody = requestBody;
|
|
240
|
+
// Remove any stale in:body entry from parameters (OAS 3.0 doesn't allow it)
|
|
241
|
+
if (Array.isArray(swaggerJson.paths[opPath][method].parameters)) {
|
|
242
|
+
swaggerJson.paths[opPath][method].parameters = swaggerJson.paths[opPath][method].parameters.filter(
|
|
243
|
+
(p) => p.in !== 'body',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
patched = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (patched) {
|
|
251
|
+
fs.writeFileSync(outputFile, JSON.stringify(swaggerJson, null, 2), 'utf8');
|
|
252
|
+
// logger.warn('swagger post-process: requestBody injected', Object.keys(requestBodies));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
151
255
|
});
|
|
152
256
|
};
|
|
153
257
|
|
|
@@ -247,4 +351,18 @@ const buildDocs = async ({
|
|
|
247
351
|
});
|
|
248
352
|
};
|
|
249
353
|
|
|
250
|
-
|
|
354
|
+
/**
|
|
355
|
+
* Builds Swagger UI customization options by rendering the SwaggerDarkMode SSR body component.
|
|
356
|
+
* Returns the customCss and customJsStr strings required by swagger-ui-express to enable
|
|
357
|
+
* a dark/light mode toggle button with a black/gray gradient dark theme.
|
|
358
|
+
* @function buildSwaggerUiOptions
|
|
359
|
+
* @memberof clientBuildDocs
|
|
360
|
+
* @returns {Promise<{customCss: string, customJsStr: string}>} Swagger UI setup options
|
|
361
|
+
*/
|
|
362
|
+
const buildSwaggerUiOptions = async () => {
|
|
363
|
+
const swaggerDarkMode = await ssrFactory('./src/client/ssr/body/SwaggerDarkMode.js');
|
|
364
|
+
const { css, js } = swaggerDarkMode();
|
|
365
|
+
return { customCss: css, customJsStr: js };
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
export { buildDocs, buildSwaggerUiOptions };
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight IPFS HTTP client for communicating with a Kubo (go-ipfs) node
|
|
3
|
+
* and an IPFS Cluster daemon running side-by-side in the same StatefulSet.
|
|
4
|
+
*
|
|
5
|
+
* Kubo API (port 5001) – add / pin / cat content.
|
|
6
|
+
* Cluster API (port 9094) – replicate pins across the cluster.
|
|
7
|
+
*
|
|
8
|
+
* Uses native `FormData` + `Blob` (Node ≥ 18) for reliable multipart encoding.
|
|
9
|
+
*
|
|
10
|
+
* @module src/server/ipfs-client.js
|
|
11
|
+
* @namespace IpfsClient
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import stringify from 'fast-json-stable-stringify';
|
|
15
|
+
import { loggerFactory } from './logger.js';
|
|
16
|
+
|
|
17
|
+
const logger = loggerFactory(import.meta);
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────
|
|
20
|
+
// URL helpers
|
|
21
|
+
// ─────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Base URL of the Kubo RPC API (port 5001).
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
const getIpfsApiUrl = () =>
|
|
28
|
+
process.env.IPFS_API_URL || `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:5001`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Base URL of the IPFS Cluster REST API (port 9094).
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
const getClusterApiUrl = () =>
|
|
35
|
+
process.env.IPFS_CLUSTER_API_URL ||
|
|
36
|
+
`http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:9094`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Base URL of the IPFS HTTP Gateway (port 8080).
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
const getGatewayUrl = () =>
|
|
43
|
+
process.env.IPFS_GATEWAY_URL ||
|
|
44
|
+
`http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:8080`;
|
|
45
|
+
|
|
46
|
+
// ─────────────────────────────────────────────────────────
|
|
47
|
+
// Core: add content
|
|
48
|
+
// ─────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} IpfsAddResult
|
|
52
|
+
* @property {string} cid – CID (Content Identifier) returned by the node.
|
|
53
|
+
* @property {number} size – Cumulative DAG size reported by the node.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Add arbitrary bytes to the Kubo node AND pin them on the IPFS Cluster.
|
|
58
|
+
*
|
|
59
|
+
* 1. `POST /api/v0/add?pin=true` to Kubo (5001) – stores + locally pins.
|
|
60
|
+
* 2. `POST /pins/<CID>` to the Cluster REST API (9094) – replicates the pin
|
|
61
|
+
* across every peer so `GET /pins` on the cluster shows the content.
|
|
62
|
+
* 3. Copies into MFS so the Web UI "Files" section shows the file.
|
|
63
|
+
*
|
|
64
|
+
* @param {Buffer|string} content – raw bytes or a UTF-8 string to store.
|
|
65
|
+
* @param {string} [filename='data'] – logical filename for the upload.
|
|
66
|
+
* @param {string} [mfsPath] – optional full MFS path.
|
|
67
|
+
* When omitted defaults to `/pinned/<filename>`.
|
|
68
|
+
* @returns {Promise<IpfsAddResult|null>} `null` when the node is unreachable.
|
|
69
|
+
*/
|
|
70
|
+
const addToIpfs = async (content, filename = 'data', mfsPath) => {
|
|
71
|
+
const kuboUrl = getIpfsApiUrl();
|
|
72
|
+
const clusterUrl = getClusterApiUrl();
|
|
73
|
+
|
|
74
|
+
// Build multipart body using native FormData + Blob (Node ≥ 18).
|
|
75
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8');
|
|
76
|
+
const formData = new FormData();
|
|
77
|
+
formData.append('file', new Blob([buf]), filename);
|
|
78
|
+
|
|
79
|
+
// ── Step 1: add to Kubo ──────────────────────────────
|
|
80
|
+
let cid;
|
|
81
|
+
let size;
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(`${kuboUrl}/api/v0/add?pin=true&cid-version=1`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
body: formData,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const text = await res.text();
|
|
90
|
+
logger.error(`IPFS Kubo add failed (${res.status}): ${text}`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const json = await res.json();
|
|
95
|
+
cid = json.Hash;
|
|
96
|
+
size = Number(json.Size);
|
|
97
|
+
logger.info(`IPFS Kubo add OK – CID: ${cid}, size: ${size}`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
logger.warn(`IPFS Kubo node unreachable at ${kuboUrl}: ${err.message}`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Step 2: pin to the Cluster ───────────────────────
|
|
104
|
+
try {
|
|
105
|
+
const clusterRes = await fetch(`${clusterUrl}/pins/${encodeURIComponent(cid)}`, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!clusterRes.ok) {
|
|
110
|
+
const text = await clusterRes.text();
|
|
111
|
+
logger.warn(`IPFS Cluster pin failed (${clusterRes.status}): ${text}`);
|
|
112
|
+
} else {
|
|
113
|
+
logger.info(`IPFS Cluster pin OK – CID: ${cid}`);
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
logger.warn(`IPFS Cluster unreachable at ${clusterUrl}: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Step 3: copy into MFS so the Web UI "Files" section shows it ─
|
|
120
|
+
const destPath = mfsPath || `/pinned/${filename}`;
|
|
121
|
+
const destDir = destPath.substring(0, destPath.lastIndexOf('/')) || '/';
|
|
122
|
+
try {
|
|
123
|
+
// Ensure parent directory exists in MFS
|
|
124
|
+
await fetch(`${kuboUrl}/api/v0/files/mkdir?arg=${encodeURIComponent(destDir)}&parents=true`, { method: 'POST' });
|
|
125
|
+
|
|
126
|
+
// Remove existing entry if present (cp fails on duplicates)
|
|
127
|
+
await fetch(`${kuboUrl}/api/v0/files/rm?arg=${encodeURIComponent(destPath)}&force=true`, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Copy the CID into MFS
|
|
132
|
+
const cpRes = await fetch(
|
|
133
|
+
`${kuboUrl}/api/v0/files/cp?arg=/ipfs/${encodeURIComponent(cid)}&arg=${encodeURIComponent(destPath)}`,
|
|
134
|
+
{ method: 'POST' },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!cpRes.ok) {
|
|
138
|
+
const text = await cpRes.text();
|
|
139
|
+
logger.warn(`IPFS MFS cp failed (${cpRes.status}): ${text}`);
|
|
140
|
+
} else {
|
|
141
|
+
logger.info(`IPFS MFS cp OK – ${destPath} → ${cid}`);
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logger.warn(`IPFS MFS cp unreachable: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { cid, size };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// ─────────────────────────────────────────────────────────
|
|
151
|
+
// Convenience wrappers
|
|
152
|
+
// ─────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Add a JSON-serialisable object to IPFS.
|
|
156
|
+
*
|
|
157
|
+
* @param {any} obj – value to serialise.
|
|
158
|
+
* @param {string} [filename='data.json']
|
|
159
|
+
* @param {string} [mfsPath] – optional full MFS destination path.
|
|
160
|
+
* @returns {Promise<IpfsAddResult|null>}
|
|
161
|
+
*/
|
|
162
|
+
const addJsonToIpfs = async (obj, filename = 'data.json', mfsPath) => {
|
|
163
|
+
const payload = stringify(obj);
|
|
164
|
+
return addToIpfs(Buffer.from(payload, 'utf-8'), filename, mfsPath);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Add a binary buffer (e.g. a PNG image) to IPFS.
|
|
169
|
+
*
|
|
170
|
+
* @param {Buffer} buffer – raw image / file bytes.
|
|
171
|
+
* @param {string} filename – e.g. `"atlas.png"`.
|
|
172
|
+
* @param {string} [mfsPath] – optional full MFS destination path.
|
|
173
|
+
* @returns {Promise<IpfsAddResult|null>}
|
|
174
|
+
*/
|
|
175
|
+
const addBufferToIpfs = async (buffer, filename, mfsPath) => {
|
|
176
|
+
return addToIpfs(buffer, filename, mfsPath);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// ─────────────────────────────────────────────────────────
|
|
180
|
+
// Pin management
|
|
181
|
+
// ─────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Explicitly pin an existing CID on both the Kubo node and the Cluster.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} cid
|
|
187
|
+
* @param {string} [type='recursive'] – `'recursive'` | `'direct'`
|
|
188
|
+
* @returns {Promise<boolean>} `true` when at least the Kubo pin succeeded.
|
|
189
|
+
*/
|
|
190
|
+
const pinCid = async (cid, type = 'recursive') => {
|
|
191
|
+
const kuboUrl = getIpfsApiUrl();
|
|
192
|
+
const clusterUrl = getClusterApiUrl();
|
|
193
|
+
let kuboOk = false;
|
|
194
|
+
|
|
195
|
+
// Kubo pin
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch(`${kuboUrl}/api/v0/pin/add?arg=${encodeURIComponent(cid)}&type=${type}`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
});
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
const text = await res.text();
|
|
202
|
+
logger.error(`IPFS Kubo pin/add failed (${res.status}): ${text}`);
|
|
203
|
+
} else {
|
|
204
|
+
kuboOk = true;
|
|
205
|
+
logger.info(`IPFS Kubo pin OK – CID: ${cid} (${type})`);
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
logger.warn(`IPFS Kubo pin unreachable: ${err.message}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Cluster pin
|
|
212
|
+
try {
|
|
213
|
+
const clusterRes = await fetch(`${clusterUrl}/pins/${encodeURIComponent(cid)}`, {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
});
|
|
216
|
+
if (!clusterRes.ok) {
|
|
217
|
+
const text = await clusterRes.text();
|
|
218
|
+
logger.warn(`IPFS Cluster pin failed (${clusterRes.status}): ${text}`);
|
|
219
|
+
} else {
|
|
220
|
+
logger.info(`IPFS Cluster pin OK – CID: ${cid}`);
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
logger.warn(`IPFS Cluster pin unreachable: ${err.message}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return kuboOk;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Unpin a CID from both the Kubo node and the Cluster.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} cid
|
|
233
|
+
* @returns {Promise<boolean>}
|
|
234
|
+
*/
|
|
235
|
+
const unpinCid = async (cid) => {
|
|
236
|
+
const kuboUrl = getIpfsApiUrl();
|
|
237
|
+
const clusterUrl = getClusterApiUrl();
|
|
238
|
+
let kuboOk = false;
|
|
239
|
+
|
|
240
|
+
// Cluster unpin
|
|
241
|
+
try {
|
|
242
|
+
const clusterRes = await fetch(`${clusterUrl}/pins/${encodeURIComponent(cid)}`, {
|
|
243
|
+
method: 'DELETE',
|
|
244
|
+
});
|
|
245
|
+
if (!clusterRes.ok) {
|
|
246
|
+
const text = await clusterRes.text();
|
|
247
|
+
logger.warn(`IPFS Cluster unpin failed (${clusterRes.status}): ${text}`);
|
|
248
|
+
} else {
|
|
249
|
+
logger.info(`IPFS Cluster unpin OK – CID: ${cid}`);
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
logger.warn(`IPFS Cluster unpin unreachable: ${err.message}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Kubo unpin
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch(`${kuboUrl}/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
});
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
const text = await res.text();
|
|
262
|
+
// "not pinned or pinned indirectly" means the CID is already unpinned – treat as success
|
|
263
|
+
if (text.includes('not pinned')) {
|
|
264
|
+
kuboOk = true;
|
|
265
|
+
logger.info(`IPFS Kubo unpin – CID already not pinned: ${cid}`);
|
|
266
|
+
} else {
|
|
267
|
+
logger.warn(`IPFS Kubo pin/rm failed (${res.status}): ${text}`);
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
kuboOk = true;
|
|
271
|
+
logger.info(`IPFS Kubo unpin OK – CID: ${cid}`);
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
logger.warn(`IPFS Kubo unpin unreachable: ${err.message}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return kuboOk;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// ─────────────────────────────────────────────────────────
|
|
281
|
+
// Retrieval
|
|
282
|
+
// ─────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Retrieve raw bytes for a CID from the IPFS HTTP Gateway (port 8080).
|
|
286
|
+
*
|
|
287
|
+
* @param {string} cid
|
|
288
|
+
* @returns {Promise<Buffer|null>}
|
|
289
|
+
*/
|
|
290
|
+
const getFromIpfs = async (cid) => {
|
|
291
|
+
const url = getGatewayUrl();
|
|
292
|
+
try {
|
|
293
|
+
const res = await fetch(`${url}/ipfs/${encodeURIComponent(cid)}`);
|
|
294
|
+
if (!res.ok) {
|
|
295
|
+
logger.error(`IPFS gateway GET failed (${res.status}) for ${cid}`);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
299
|
+
return Buffer.from(arrayBuffer);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
logger.warn(`IPFS gateway unreachable at ${url}: ${err.message}`);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// ─────────────────────────────────────────────────────────
|
|
307
|
+
// Diagnostics
|
|
308
|
+
// ─────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* List all pins tracked by the IPFS Cluster (port 9094).
|
|
312
|
+
* Each line in the response is a JSON object with at least a `cid` field.
|
|
313
|
+
*
|
|
314
|
+
* @returns {Promise<Array<{ cid: string, name: string, peer_map: object }>>}
|
|
315
|
+
*/
|
|
316
|
+
const listClusterPins = async () => {
|
|
317
|
+
const clusterUrl = getClusterApiUrl();
|
|
318
|
+
try {
|
|
319
|
+
const res = await fetch(`${clusterUrl}/pins`);
|
|
320
|
+
if (res.status === 204) {
|
|
321
|
+
// 204 No Content → the cluster has no pins at all.
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
if (!res.ok) {
|
|
325
|
+
const text = await res.text();
|
|
326
|
+
logger.error(`IPFS Cluster list pins failed (${res.status}): ${text}`);
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
const text = await res.text();
|
|
330
|
+
// The cluster streams one JSON object per line (NDJSON).
|
|
331
|
+
return text
|
|
332
|
+
.split('\n')
|
|
333
|
+
.filter((line) => line.trim())
|
|
334
|
+
.map((line) => {
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(line);
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
.filter(Boolean);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
logger.warn(`IPFS Cluster unreachable at ${clusterUrl}: ${err.message}`);
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* List pins tracked by the local Kubo node (port 5001).
|
|
350
|
+
*
|
|
351
|
+
* @param {string} [type='recursive'] – `'all'` | `'recursive'` | `'direct'` | `'indirect'`
|
|
352
|
+
* @returns {Promise<Object<string, { Type: string }>>} Map of CID → pin info.
|
|
353
|
+
*/
|
|
354
|
+
const listKuboPins = async (type = 'recursive') => {
|
|
355
|
+
const kuboUrl = getIpfsApiUrl();
|
|
356
|
+
try {
|
|
357
|
+
const res = await fetch(`${kuboUrl}/api/v0/pin/ls?type=${type}`, {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
});
|
|
360
|
+
if (!res.ok) {
|
|
361
|
+
const text = await res.text();
|
|
362
|
+
logger.error(`IPFS Kubo pin/ls failed (${res.status}): ${text}`);
|
|
363
|
+
return {};
|
|
364
|
+
}
|
|
365
|
+
const json = await res.json();
|
|
366
|
+
return json.Keys || {};
|
|
367
|
+
} catch (err) {
|
|
368
|
+
logger.warn(`IPFS Kubo pin/ls unreachable: ${err.message}`);
|
|
369
|
+
return {};
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// ─────────────────────────────────────────────────────────
|
|
374
|
+
// MFS management
|
|
375
|
+
// ─────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Remove a file or directory from the Kubo MFS (Mutable File System).
|
|
379
|
+
* This cleans up entries visible in the IPFS Web UI "Files" section.
|
|
380
|
+
*
|
|
381
|
+
* @param {string} mfsPath – Full MFS path to remove, e.g. `/pinned/myfile.json`
|
|
382
|
+
* or `/object-layer/itemId`.
|
|
383
|
+
* @param {boolean} [recursive=true] – When `true`, removes directories recursively.
|
|
384
|
+
* @returns {Promise<boolean>} `true` when the removal succeeded or the path didn't exist.
|
|
385
|
+
*/
|
|
386
|
+
const removeMfsPath = async (mfsPath, recursive = true) => {
|
|
387
|
+
const kuboUrl = getIpfsApiUrl();
|
|
388
|
+
try {
|
|
389
|
+
// First check if the path exists via stat; if it doesn't we can return early.
|
|
390
|
+
const statRes = await fetch(`${kuboUrl}/api/v0/files/stat?arg=${encodeURIComponent(mfsPath)}`, { method: 'POST' });
|
|
391
|
+
if (!statRes.ok) {
|
|
392
|
+
// Path doesn't exist – nothing to remove.
|
|
393
|
+
logger.info(`IPFS MFS rm – path does not exist, skipping: ${mfsPath}`);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const rmRes = await fetch(
|
|
398
|
+
`${kuboUrl}/api/v0/files/rm?arg=${encodeURIComponent(mfsPath)}&force=true${recursive ? '&recursive=true' : ''}`,
|
|
399
|
+
{ method: 'POST' },
|
|
400
|
+
);
|
|
401
|
+
if (!rmRes.ok) {
|
|
402
|
+
const text = await rmRes.text();
|
|
403
|
+
logger.warn(`IPFS MFS rm failed (${rmRes.status}): ${text}`);
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
logger.info(`IPFS MFS rm OK – ${mfsPath}`);
|
|
407
|
+
return true;
|
|
408
|
+
} catch (err) {
|
|
409
|
+
logger.warn(`IPFS MFS rm unreachable: ${err.message}`);
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// ─────────────────────────────────────────────────────────
|
|
415
|
+
// Export
|
|
416
|
+
// ─────────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
const IpfsClient = {
|
|
419
|
+
getIpfsApiUrl,
|
|
420
|
+
getClusterApiUrl,
|
|
421
|
+
getGatewayUrl,
|
|
422
|
+
addToIpfs,
|
|
423
|
+
addJsonToIpfs,
|
|
424
|
+
addBufferToIpfs,
|
|
425
|
+
pinCid,
|
|
426
|
+
unpinCid,
|
|
427
|
+
getFromIpfs,
|
|
428
|
+
listClusterPins,
|
|
429
|
+
listKuboPins,
|
|
430
|
+
removeMfsPath,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
export { IpfsClient };
|