cyberia 2.99.8 → 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.
Files changed (81) hide show
  1. package/.env.production +1 -0
  2. package/.github/workflows/engine-cyberia.cd.yml +1 -0
  3. package/.github/workflows/gitlab.ci.yml +20 -0
  4. package/.github/workflows/publish.ci.yml +18 -38
  5. package/.github/workflows/publish.cyberia.ci.yml +18 -38
  6. package/.vscode/extensions.json +8 -50
  7. package/.vscode/settings.json +0 -77
  8. package/CHANGELOG.md +171 -1
  9. package/{cli.md → CLI-HELP.md} +49 -44
  10. package/README.md +139 -0
  11. package/bin/build.js +7 -15
  12. package/bin/cyberia.js +385 -71
  13. package/bin/deploy.js +14 -151
  14. package/bin/file.js +13 -8
  15. package/bin/index.js +385 -71
  16. package/bin/zed.js +63 -2
  17. package/conf.js +32 -3
  18. package/deployment.yaml +2 -2
  19. package/jsdoc.json +1 -2
  20. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  21. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  22. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  23. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  24. package/manifests/deployment/fastapi/initial_data.sh +4 -52
  25. package/manifests/ipfs/configmap.yaml +64 -0
  26. package/manifests/ipfs/headless-service.yaml +35 -0
  27. package/manifests/ipfs/kustomization.yaml +8 -0
  28. package/manifests/ipfs/statefulset.yaml +149 -0
  29. package/manifests/ipfs/storage-class.yaml +9 -0
  30. package/package.json +15 -11
  31. package/scripts/k3s-node-setup.sh +89 -0
  32. package/scripts/lxd-vm-setup.sh +23 -0
  33. package/scripts/rocky-setup.sh +1 -13
  34. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +2 -0
  35. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +7 -0
  36. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +93 -2
  37. package/src/api/file/file.controller.js +3 -13
  38. package/src/api/file/file.ref.json +0 -21
  39. package/src/api/ipfs/ipfs.controller.js +104 -0
  40. package/src/api/ipfs/ipfs.model.js +71 -0
  41. package/src/api/ipfs/ipfs.router.js +31 -0
  42. package/src/api/ipfs/ipfs.service.js +193 -0
  43. package/src/api/object-layer/README.md +139 -0
  44. package/src/api/object-layer/object-layer.controller.js +3 -0
  45. package/src/api/object-layer/object-layer.model.js +15 -1
  46. package/src/api/object-layer/object-layer.router.js +6 -10
  47. package/src/api/object-layer/object-layer.service.js +311 -182
  48. package/src/api/user/user.router.js +0 -47
  49. package/src/cli/baremetal.js +7 -9
  50. package/src/cli/cluster.js +95 -152
  51. package/src/cli/deploy.js +8 -5
  52. package/src/cli/index.js +31 -31
  53. package/src/cli/ipfs.js +184 -0
  54. package/src/cli/lxd.js +192 -237
  55. package/src/cli/repository.js +4 -1
  56. package/src/cli/run.js +17 -2
  57. package/src/client/components/core/Docs.js +92 -6
  58. package/src/client/components/core/LoadingAnimation.js +2 -3
  59. package/src/client/components/core/Modal.js +1 -1
  60. package/src/client/components/core/VanillaJs.js +36 -25
  61. package/src/client/components/cyberia/ObjectLayerEngineModal.js +4 -5
  62. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +280 -29
  63. package/src/client/services/ipfs/ipfs.service.js +144 -0
  64. package/src/client/services/object-layer/object-layer.management.js +161 -8
  65. package/src/client/services/user/user.management.js +0 -5
  66. package/src/client/services/user/user.service.js +1 -1
  67. package/src/index.js +12 -1
  68. package/src/runtime/express/Express.js +4 -3
  69. package/src/server/auth.js +18 -18
  70. package/src/server/client-build-docs.js +178 -41
  71. package/src/server/conf.js +1 -1
  72. package/src/server/ipfs-client.js +433 -0
  73. package/src/server/logger.js +22 -10
  74. package/src/server/object-layer.js +649 -18
  75. package/src/server/semantic-layer-generator.js +1083 -0
  76. package/src/server/shape-generator.js +952 -0
  77. package/test/shape-generator.test.js +457 -0
  78. package/.vscode/zed.keymap.json +0 -39
  79. package/.vscode/zed.settings.json +0 -20
  80. package/bin/ssl.js +0 -63
  81. package/manifests/lxd/underpost-setup.sh +0 -163
@@ -103,16 +103,4 @@ echo "[+] Cleanup: remove unnecessary packages and old metadata"
103
103
  dnf autoremove -y
104
104
  dnf clean all
105
105
 
106
- cat <<EOF
107
-
108
- Installation complete.
109
- - To allow SSH access (if this is a VM or server), open port 22 in firewalld:
110
- sudo firewall-cmd --add-service=ssh --permanent && sudo firewall-cmd --reload
111
- - If you installed Development Tools, you will have gcc, make, etc.
112
-
113
- Examples:
114
- sudo ./scripts/rocky-setup.sh --install-dev
115
- INSTALL_DEV=1 sudo ./scripts/rocky-setup.sh
116
-
117
- Customize PACKAGES=(...) inside this script according to your needs (docker, podman, kube, mssql-tools, etc.).
118
- EOF
106
+ echo "[+] Setup complete! Rocky Linux is now configured with the recommended base packages."
@@ -51,6 +51,8 @@ const AtlasSpriteSheetController = {
51
51
  },
52
52
  get: async (req, res, options) => {
53
53
  try {
54
+ res.setHeader('Access-Control-Allow-Origin', '*');
55
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
54
56
  const { page, limit } = req.query;
55
57
  const result = await AtlasSpriteSheetService.get(
56
58
  { ...req, query: { ...req.query, page: parseInt(page), limit: parseInt(limit) } },
@@ -75,6 +75,7 @@ const DirectionFramesSchema = new Schema(
75
75
  /**
76
76
  * @typedef {Object} AtlasSpriteSheet
77
77
  * @property {Types.ObjectId} fileId - Reference to File document (consolidated PNG)
78
+ * @property {string} [cid] - IPFS Content Identifier for the atlas PNG
78
79
  * @property {Object} metadata - Atlas sprite sheet metadata
79
80
  * @property {string} metadata.itemKey - Item identifier key for texture reference
80
81
  * @property {number} metadata.atlasWidth - Total atlas width in pixels
@@ -92,6 +93,11 @@ const AtlasSpriteSheetSchema = new Schema(
92
93
  ref: 'File',
93
94
  required: true,
94
95
  },
96
+ cid: {
97
+ type: String,
98
+ default: '',
99
+ trim: true,
100
+ },
95
101
  metadata: {
96
102
  itemKey: { type: String, required: true, trim: true },
97
103
  atlasWidth: { type: Number, required: true, min: 1 },
@@ -129,6 +135,7 @@ const AtlasSpriteSheetDto = {
129
135
  return {
130
136
  _id: 1,
131
137
  fileId: 1,
138
+ cid: 1,
132
139
  metadata: 1,
133
140
  createdAt: 1,
134
141
  updatedAt: 1,
@@ -4,6 +4,8 @@ import { DataQuery } from '../../server/data-query.js';
4
4
  import { AtlasSpriteSheetGenerator } from '../../server/atlas-sprite-sheet-generator.js';
5
5
  import { FileFactory } from '../file/file.service.js';
6
6
  import { AtlasSpriteSheetDto } from './atlas-sprite-sheet.model.js';
7
+ import { IpfsClient } from '../../server/ipfs-client.js';
8
+ import { createPinRecord, removePinRecordsAndUnpin } from '../ipfs/ipfs.service.js';
7
9
 
8
10
  const logger = loggerFactory(import.meta);
9
11
 
@@ -39,6 +41,27 @@ const AtlasSpriteSheetService = {
39
41
 
40
42
  const fileDoc = await new File(FileFactory.create(buffer, `${itemKey}.png`)).save();
41
43
 
44
+ // Add atlas PNG to IPFS and obtain its CID
45
+ let atlasCid = '';
46
+ try {
47
+ const ipfsResult = await IpfsClient.addBufferToIpfs(
48
+ buffer,
49
+ `${itemKey}_atlas_sprite_sheet.png`,
50
+ `/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`,
51
+ );
52
+ if (ipfsResult) {
53
+ atlasCid = ipfsResult.cid;
54
+ // Create pin record for the authenticated user (when available)
55
+ const userId = req.auth && req.auth.user ? req.auth.user._id : undefined;
56
+ if (userId) {
57
+ await createPinRecord({ cid: atlasCid, userId, options });
58
+ }
59
+ logger.info(`Atlas sprite sheet pinned to IPFS – CID: ${atlasCid}`);
60
+ }
61
+ } catch (ipfsError) {
62
+ logger.warn('Failed to add atlas sprite sheet to IPFS:', ipfsError.message);
63
+ }
64
+
42
65
  let atlasDoc = await AtlasSpriteSheet.findOne({ 'metadata.itemKey': itemKey });
43
66
 
44
67
  if (atlasDoc) {
@@ -47,16 +70,20 @@ const AtlasSpriteSheetService = {
47
70
  await File.findByIdAndDelete(atlasDoc.fileId);
48
71
  }
49
72
  atlasDoc.fileId = fileDoc._id;
73
+ atlasDoc.cid = atlasCid;
50
74
  atlasDoc.metadata = metadata;
51
75
  await atlasDoc.save();
52
76
  } else {
53
77
  atlasDoc = await new AtlasSpriteSheet({
54
78
  fileId: fileDoc._id,
79
+ cid: atlasCid,
55
80
  metadata,
56
81
  }).save();
57
82
  }
58
83
 
59
84
  objectLayer.atlasSpriteSheetId = atlasDoc._id;
85
+ objectLayer.data.atlasSpriteSheetCid = atlasCid;
86
+ objectLayer.markModified('data.atlasSpriteSheetCid');
60
87
  await objectLayer.save();
61
88
 
62
89
  return atlasDoc;
@@ -78,12 +105,30 @@ const AtlasSpriteSheetService = {
78
105
  if (objectLayer.atlasSpriteSheetId) {
79
106
  const atlasDoc = await AtlasSpriteSheet.findById(objectLayer.atlasSpriteSheetId);
80
107
  if (atlasDoc) {
108
+ // Remove pin records and unpin atlas CID from IPFS
109
+ const atlasCid = atlasDoc.cid || objectLayer.data.atlasSpriteSheetCid;
110
+ if (atlasCid) {
111
+ try {
112
+ await removePinRecordsAndUnpin(atlasCid, options);
113
+ // Remove the MFS entry for the atlas sprite sheet PNG
114
+ const itemId = objectLayer.data.item.id;
115
+ await IpfsClient.removeMfsPath(`/object-layer/${itemId}/${itemId}_atlas_sprite_sheet.png`);
116
+ logger.info(`Cleaned up IPFS atlas CID ${atlasCid} for ObjectLayer ${objectLayer._id}`);
117
+ } catch (ipfsErr) {
118
+ logger.warn(`Failed to clean up IPFS atlas CID ${atlasCid}: ${ipfsErr.message}`);
119
+ }
120
+ }
121
+
122
+ // Delete the atlas File document from MongoDB
81
123
  if (atlasDoc.fileId) {
82
124
  await File.findByIdAndDelete(atlasDoc.fileId);
83
125
  }
126
+ // Delete the AtlasSpriteSheet document itself
84
127
  await AtlasSpriteSheet.findByIdAndDelete(atlasDoc._id);
85
128
  }
86
129
  objectLayer.atlasSpriteSheetId = undefined;
130
+ objectLayer.data.atlasSpriteSheetCid = '';
131
+ objectLayer.markModified('data.atlasSpriteSheetCid');
87
132
  await objectLayer.save();
88
133
  }
89
134
 
@@ -130,8 +175,54 @@ const AtlasSpriteSheetService = {
130
175
  /** @type {import('./atlas-sprite-sheet.model.js').AtlasSpriteSheetModel} */
131
176
  const AtlasSpriteSheet =
132
177
  DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.AtlasSpriteSheet;
133
- if (req.params.id) return await AtlasSpriteSheet.findByIdAndDelete(req.params.id);
134
- else return await AtlasSpriteSheet.deleteMany();
178
+ /** @type {import('../file/file.model.js').FileModel} */
179
+ const File = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.File;
180
+
181
+ if (req.params.id) {
182
+ const atlasDoc = await AtlasSpriteSheet.findById(req.params.id);
183
+ if (!atlasDoc) return null;
184
+
185
+ // Remove pin records and unpin atlas CID from IPFS
186
+ if (atlasDoc.cid) {
187
+ try {
188
+ await removePinRecordsAndUnpin(atlasDoc.cid, options);
189
+ if (atlasDoc.metadata?.itemKey) {
190
+ const itemKey = atlasDoc.metadata.itemKey;
191
+ await IpfsClient.removeMfsPath(`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`);
192
+ }
193
+ logger.info(`Cleaned up IPFS atlas CID ${atlasDoc.cid} for AtlasSpriteSheet ${atlasDoc._id}`);
194
+ } catch (ipfsErr) {
195
+ logger.warn(`Failed to clean up IPFS atlas CID ${atlasDoc.cid}: ${ipfsErr.message}`);
196
+ }
197
+ }
198
+
199
+ // Delete the referenced File document (the atlas PNG blob)
200
+ if (atlasDoc.fileId) {
201
+ await File.findByIdAndDelete(atlasDoc.fileId);
202
+ }
203
+
204
+ return await AtlasSpriteSheet.findByIdAndDelete(req.params.id);
205
+ } else {
206
+ // Bulk delete: iterate each atlas to clean up File, IPFS pins, and pin records
207
+ const allAtlases = await AtlasSpriteSheet.find({});
208
+ for (const atlasDoc of allAtlases) {
209
+ try {
210
+ if (atlasDoc.cid) {
211
+ await removePinRecordsAndUnpin(atlasDoc.cid, options);
212
+ if (atlasDoc.metadata?.itemKey) {
213
+ const itemKey = atlasDoc.metadata.itemKey;
214
+ await IpfsClient.removeMfsPath(`/object-layer/${itemKey}/${itemKey}_atlas_sprite_sheet.png`);
215
+ }
216
+ }
217
+ if (atlasDoc.fileId) {
218
+ await File.findByIdAndDelete(atlasDoc.fileId);
219
+ }
220
+ } catch (err) {
221
+ logger.error(`Failed to clean up AtlasSpriteSheet ${atlasDoc._id} during bulk delete: ${err.message}`);
222
+ }
223
+ }
224
+ return await AtlasSpriteSheet.deleteMany();
225
+ }
135
226
  },
136
227
  };
137
228
 
@@ -19,22 +19,12 @@ const FileController = {
19
19
  },
20
20
  get: async (req, res, options) => {
21
21
  try {
22
+ res.setHeader('Access-Control-Allow-Origin', '*');
23
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
22
24
  const result = await FileService.get(req, res, options);
23
25
  if (result instanceof Buffer) {
24
- if (
25
- process.env.NODE_ENV === 'development' ||
26
- req.hostname === options.host ||
27
- (options.origins && options.origins.find((o) => o.match(req.hostname)))
28
- ) {
29
- res.set('Cross-Origin-Resource-Policy', 'cross-origin');
30
- return res.status(200).end(result);
31
- }
32
- return res.status(403).json({
33
- status: 'error',
34
- message: 'Forbidden',
35
- });
26
+ return res.status(200).end(result);
36
27
  }
37
-
38
28
  return res.status(200).json({
39
29
  status: 'success',
40
30
  data: result,
@@ -5,27 +5,6 @@
5
5
  "logo": true
6
6
  }
7
7
  },
8
- {
9
- "api": "cyberia-biome",
10
- "model": {
11
- "fileId": true,
12
- "topLevelColorFileId": true
13
- }
14
- },
15
- {
16
- "api": "cyberia-tile",
17
- "model": {
18
- "fileId": true
19
- }
20
- },
21
- {
22
- "api": "cyberia-world",
23
- "model": {
24
- "adjacentFace": {
25
- "fileId": true
26
- }
27
- }
28
- },
29
8
  {
30
9
  "api": "document",
31
10
  "model": {
@@ -0,0 +1,104 @@
1
+ import { loggerFactory } from '../../server/logger.js';
2
+ import { IpfsService } from './ipfs.service.js';
3
+
4
+ const logger = loggerFactory(import.meta);
5
+
6
+ const IpfsController = {
7
+ post: async (req, res, options) => {
8
+ try {
9
+ const result = await IpfsService.post(req, res, options);
10
+ return res.status(200).json({
11
+ status: 'success',
12
+ data: result,
13
+ });
14
+ } catch (error) {
15
+ logger.error(error, error.stack);
16
+ return res.status(400).json({
17
+ status: 'error',
18
+ message: error.message,
19
+ });
20
+ }
21
+ },
22
+ get: async (req, res, options) => {
23
+ try {
24
+ const { page, limit } = req.query;
25
+ const result = await IpfsService.get(
26
+ { ...req, query: { ...req.query, page: parseInt(page), limit: parseInt(limit) } },
27
+ res,
28
+ options,
29
+ );
30
+ return res.status(200).json({
31
+ status: 'success',
32
+ data: result,
33
+ });
34
+ } catch (error) {
35
+ logger.error(error, error.stack);
36
+ return res.status(400).json({
37
+ status: 'error',
38
+ message: error.message,
39
+ });
40
+ }
41
+ },
42
+ put: async (req, res, options) => {
43
+ try {
44
+ const result = await IpfsService.put(req, res, options);
45
+ return res.status(200).json({
46
+ status: 'success',
47
+ data: result,
48
+ });
49
+ } catch (error) {
50
+ logger.error(error, error.stack);
51
+ return res.status(400).json({
52
+ status: 'error',
53
+ message: error.message,
54
+ });
55
+ }
56
+ },
57
+ delete: async (req, res, options) => {
58
+ try {
59
+ const result = await IpfsService.delete(req, res, options);
60
+ return res.status(200).json({
61
+ status: 'success',
62
+ data: result,
63
+ });
64
+ } catch (error) {
65
+ logger.error(error, error.stack);
66
+ return res.status(400).json({
67
+ status: 'error',
68
+ message: error.message,
69
+ });
70
+ }
71
+ },
72
+ pin: async (req, res, options) => {
73
+ try {
74
+ const result = await IpfsService.pin(req, res, options);
75
+ return res.status(200).json({
76
+ status: 'success',
77
+ data: result,
78
+ });
79
+ } catch (error) {
80
+ logger.error(error, error.stack);
81
+ return res.status(400).json({
82
+ status: 'error',
83
+ message: error.message,
84
+ });
85
+ }
86
+ },
87
+ unpin: async (req, res, options) => {
88
+ try {
89
+ const result = await IpfsService.unpin(req, res, options);
90
+ return res.status(200).json({
91
+ status: 'success',
92
+ data: result,
93
+ });
94
+ } catch (error) {
95
+ logger.error(error, error.stack);
96
+ return res.status(400).json({
97
+ status: 'error',
98
+ message: error.message,
99
+ });
100
+ }
101
+ },
102
+ };
103
+
104
+ export { IpfsController };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Mongoose model for IPFS API – a general-purpose pin record that relates
3
+ * a user to a CID.
4
+ *
5
+ * @module src/api/ipfs/ipfs.model.js
6
+ * @namespace IpfsModel
7
+ */
8
+
9
+ import { Schema, model } from 'mongoose';
10
+
11
+ /**
12
+ * @typedef {Object} Ipfs
13
+ * @property {string} cid – IPFS Content Identifier (CIDv0 or CIDv1).
14
+ * @property {Types.ObjectId} userId – Reference to the User who owns / requested the pin.
15
+ * @property {Date} createdAt – Auto-managed by Mongoose.
16
+ * @property {Date} updatedAt – Auto-managed by Mongoose.
17
+ * @memberof IpfsModel
18
+ */
19
+ const IpfsSchema = new Schema(
20
+ {
21
+ cid: {
22
+ type: String,
23
+ required: true,
24
+ trim: true,
25
+ },
26
+ userId: {
27
+ type: Schema.Types.ObjectId,
28
+ ref: 'User',
29
+ required: true,
30
+ },
31
+ },
32
+ {
33
+ timestamps: true,
34
+ },
35
+ );
36
+
37
+ // Compound index: one pin record per user + CID pair.
38
+ IpfsSchema.index({ cid: 1, userId: 1 }, { unique: true });
39
+
40
+ // Fast look-ups by user.
41
+ IpfsSchema.index({ userId: 1 });
42
+
43
+ // Fast look-ups by CID.
44
+ IpfsSchema.index({ cid: 1 });
45
+
46
+ const IpfsModel = model('Ipfs', IpfsSchema);
47
+
48
+ const ProviderSchema = IpfsSchema;
49
+
50
+ const IpfsDto = {
51
+ select: {
52
+ get: () => {
53
+ return {
54
+ _id: 1,
55
+ cid: 1,
56
+ userId: 1,
57
+ createdAt: 1,
58
+ updatedAt: 1,
59
+ };
60
+ },
61
+ },
62
+ populate: {
63
+ user: () => ({
64
+ path: 'userId',
65
+ model: 'User',
66
+ select: '_id username email role',
67
+ }),
68
+ },
69
+ };
70
+
71
+ export { IpfsSchema, IpfsModel, ProviderSchema, IpfsDto };
@@ -0,0 +1,31 @@
1
+ import { adminGuard } from '../../server/auth.js';
2
+ import { loggerFactory } from '../../server/logger.js';
3
+ import { IpfsController } from './ipfs.controller.js';
4
+ import express from 'express';
5
+
6
+ const logger = loggerFactory(import.meta);
7
+
8
+ const IpfsRouter = (options) => {
9
+ const router = express.Router();
10
+ const authMiddleware = options.authMiddleware;
11
+ router.post(`/pin`, authMiddleware, adminGuard, async (req, res) => await IpfsController.pin(req, res, options));
12
+ router.delete(
13
+ `/pin/:cid`,
14
+ authMiddleware,
15
+ adminGuard,
16
+ async (req, res) => await IpfsController.unpin(req, res, options),
17
+ );
18
+ router.post(`/:id`, authMiddleware, async (req, res) => await IpfsController.post(req, res, options));
19
+ router.post(`/`, authMiddleware, async (req, res) => await IpfsController.post(req, res, options));
20
+ router.get(`/:id`, authMiddleware, async (req, res) => await IpfsController.get(req, res, options));
21
+ router.get(`/`, authMiddleware, async (req, res) => await IpfsController.get(req, res, options));
22
+ router.put(`/:id`, authMiddleware, async (req, res) => await IpfsController.put(req, res, options));
23
+ router.put(`/`, authMiddleware, async (req, res) => await IpfsController.put(req, res, options));
24
+ router.delete(`/:id`, authMiddleware, adminGuard, async (req, res) => await IpfsController.delete(req, res, options));
25
+ router.delete(`/`, authMiddleware, adminGuard, async (req, res) => await IpfsController.delete(req, res, options));
26
+ return router;
27
+ };
28
+
29
+ const ApiRouter = IpfsRouter;
30
+
31
+ export { ApiRouter, IpfsRouter };
@@ -0,0 +1,193 @@
1
+ import { DataBaseProvider } from '../../db/DataBaseProvider.js';
2
+ import { loggerFactory } from '../../server/logger.js';
3
+ import { DataQuery } from '../../server/data-query.js';
4
+ import { IpfsClient } from '../../server/ipfs-client.js';
5
+ import { IpfsDto } from './ipfs.model.js';
6
+
7
+ const logger = loggerFactory(import.meta);
8
+
9
+ /**
10
+ * Create (or upsert) an IPFS pin record for a given user + CID pair.
11
+ * This is a helper consumed by other services (ObjectLayer, AtlasSpriteSheet, …)
12
+ * so they don't need to know about the Ipfs model directly.
13
+ *
14
+ * @param {object} opts
15
+ * @param {string} opts.cid – IPFS Content Identifier.
16
+ * @param {string} opts.userId – Mongoose ObjectId string of the owning user.
17
+ * @param {object} opts.options – Router options ({ host, path }) for DB lookup.
18
+ * @returns {Promise<import('mongoose').Document>}
19
+ */
20
+ const createPinRecord = async ({ cid, userId, options }) => {
21
+ const Ipfs = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Ipfs;
22
+
23
+ // Upsert: if a record for this user + CID already exists, just touch it.
24
+ const record = await Ipfs.findOneAndUpdate(
25
+ { cid, userId },
26
+ { cid, userId },
27
+ { upsert: true, new: true, setDefaultsOnInsert: true },
28
+ );
29
+
30
+ logger.info(`IPFS pin record upserted – CID: ${cid}, userId: ${userId}`);
31
+ return record;
32
+ };
33
+
34
+ /**
35
+ * Remove all DB pin records for a CID, then best-effort unpin from IPFS node/cluster.
36
+ * Always deletes the DB records first so that even if the IPFS node is unreachable
37
+ * the database stays clean.
38
+ *
39
+ * @param {string} cid – IPFS Content Identifier to clean up.
40
+ * @param {object} options – Router options ({ host, path }) for DB lookup.
41
+ * @returns {Promise<void>}
42
+ */
43
+ const removePinRecordsAndUnpin = async (cid, options) => {
44
+ const Ipfs = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Ipfs;
45
+
46
+ // 1. Remove all DB pin records for this CID first
47
+ await Ipfs.deleteMany({ cid });
48
+ logger.info(`Removed all IPFS pin records for CID: ${cid}`);
49
+
50
+ // 2. Best-effort unpin from IPFS node/cluster (ignore "not pinned" errors)
51
+ try {
52
+ await IpfsClient.unpinCid(cid);
53
+ } catch (err) {
54
+ logger.warn(`Best-effort IPFS unpin failed for CID ${cid}: ${err.message}`);
55
+ }
56
+ };
57
+
58
+ const IpfsService = {
59
+ /** Expose helpers so other modules can import them directly. */
60
+ createPinRecord,
61
+ removePinRecordsAndUnpin,
62
+
63
+ // ──────────────────────────────────────────────
64
+ // Standard CRUD
65
+ // ──────────────────────────────────────────────
66
+
67
+ post: async (req, res, options) => {
68
+ /** @type {import('./ipfs.model.js').IpfsModel} */
69
+ const Ipfs = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Ipfs;
70
+
71
+ // Accept { cid, userId? } in body.
72
+ // If userId is omitted, fall back to the authenticated user.
73
+ const body = { ...req.body };
74
+ if (!body.userId && req.auth && req.auth.user) {
75
+ body.userId = req.auth.user._id;
76
+ }
77
+ // Strip pinType if sent by legacy clients
78
+ delete body.pinType;
79
+
80
+ return await new Ipfs(body).save();
81
+ },
82
+
83
+ get: async (req, res, options) => {
84
+ /** @type {import('./ipfs.model.js').IpfsModel} */
85
+ const Ipfs = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Ipfs;
86
+
87
+ if (req.params.id) {
88
+ return await Ipfs.findById(req.params.id).select(IpfsDto.select.get()).populate(IpfsDto.populate.user());
89
+ }
90
+
91
+ const { query, sort, skip, limit, page } = DataQuery.parse(req.query);
92
+
93
+ const [data, total] = await Promise.all([
94
+ Ipfs.find(query)
95
+ .select(IpfsDto.select.get())
96
+ .sort(sort)
97
+ .limit(limit)
98
+ .skip(skip)
99
+ .populate(IpfsDto.populate.user()),
100
+ Ipfs.countDocuments(query),
101
+ ]);
102
+
103
+ const totalPages = Math.ceil(total / limit);
104
+ return { data, total, page, totalPages };
105
+ },
106
+
107
+ put: async (req, res, options) => {
108
+ /** @type {import('./ipfs.model.js').IpfsModel} */
109
+ const Ipfs = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Ipfs;
110
+ return await Ipfs.findByIdAndUpdate(req.params.id, req.body, { new: true });
111
+ },
112
+
113
+ delete: async (req, res, options) => {
114
+ /** @type {import('./ipfs.model.js').IpfsModel} */
115
+ const Ipfs = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Ipfs;
116
+
117
+ if (req.params.id) {
118
+ const record = await Ipfs.findById(req.params.id);
119
+ if (record) {
120
+ // Remove DB record first, then best-effort unpin
121
+ await Ipfs.findByIdAndDelete(req.params.id);
122
+ try {
123
+ // Only unpin from IPFS if no other records reference this CID
124
+ const remaining = await Ipfs.countDocuments({ cid: record.cid });
125
+ if (remaining === 0) {
126
+ await IpfsClient.unpinCid(record.cid);
127
+ }
128
+ } catch (err) {
129
+ logger.warn(`Failed to unpin CID ${record.cid} from IPFS node: ${err.message}`);
130
+ }
131
+ return record;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ return await Ipfs.deleteMany();
137
+ },
138
+
139
+ // ──────────────────────────────────────────────
140
+ // Pin / Unpin helpers (called via controller)
141
+ // ──────────────────────────────────────────────
142
+
143
+ /**
144
+ * POST /ipfs/pin – add content to IPFS, pin it, and create a DB record.
145
+ * Body: { data: <string|object>, userId? }
146
+ */
147
+ pin: async (req, res, options) => {
148
+ const userId = req.body.userId || (req.auth && req.auth.user ? req.auth.user._id : undefined);
149
+ if (!userId) throw new Error('userId is required to create a pin record');
150
+
151
+ const content = typeof req.body.data === 'string' ? req.body.data : JSON.stringify(req.body.data);
152
+ const result = await IpfsClient.addToIpfs(Buffer.from(content, 'utf-8'), req.body.filename || 'data');
153
+
154
+ if (!result) throw new Error('IPFS node is unreachable – content was not pinned');
155
+
156
+ const record = await createPinRecord({
157
+ cid: result.cid,
158
+ userId,
159
+ options,
160
+ });
161
+
162
+ return { cid: result.cid, size: result.size, record };
163
+ },
164
+
165
+ /**
166
+ * DELETE /ipfs/pin/:cid – unpin a CID and remove the DB record for the current user.
167
+ */
168
+ unpin: async (req, res, options) => {
169
+ const Ipfs = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.Ipfs;
170
+ const userId = req.auth && req.auth.user ? req.auth.user._id : undefined;
171
+ const cid = req.params.cid || req.params.id;
172
+
173
+ const record = await Ipfs.findOne({ cid, ...(userId ? { userId } : {}) });
174
+ if (!record) throw new Error(`No pin record found for CID ${cid}`);
175
+
176
+ // Remove DB record first
177
+ await Ipfs.findByIdAndDelete(record._id);
178
+
179
+ // Only unpin from the IPFS node when nobody else has a record for this CID
180
+ const remaining = await Ipfs.countDocuments({ cid });
181
+ if (remaining === 0) {
182
+ try {
183
+ await IpfsClient.unpinCid(cid);
184
+ } catch (err) {
185
+ logger.warn(`Best-effort IPFS unpin failed for CID ${cid}: ${err.message}`);
186
+ }
187
+ }
188
+
189
+ return { success: true, cid };
190
+ },
191
+ };
192
+
193
+ export { IpfsService, createPinRecord, removePinRecordsAndUnpin };