@yrpri/api 9.0.221 → 9.0.222

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/models/image.cjs CHANGED
@@ -192,6 +192,29 @@ module.exports = (sequelize, DataTypes) => {
192
192
  });
193
193
  return formats;
194
194
  };
195
+ const getBelongsToManyAssociations = () => {
196
+ return Object.entries(Image.associations || {}).filter(([, association]) => association.associationType === "BelongsToMany");
197
+ };
198
+ const hasRemainingCollectionAssociations = async (imageId) => {
199
+ const includes = getBelongsToManyAssociations().map(([associationName]) => ({
200
+ association: associationName,
201
+ required: false,
202
+ through: { attributes: [] },
203
+ }));
204
+ if (!includes.length) {
205
+ return false;
206
+ }
207
+ const imageWithAssociations = await Image.findByPk(imageId, {
208
+ include: includes,
209
+ });
210
+ if (!imageWithAssociations) {
211
+ return false;
212
+ }
213
+ return includes.some(({ association }) => {
214
+ const associatedRecords = imageWithAssociations[association];
215
+ return Array.isArray(associatedRecords) && associatedRecords.length > 0;
216
+ });
217
+ };
195
218
  Image.removeImageFromCollection = async (req, res) => {
196
219
  const imageId = req.params.imageId;
197
220
  const groupId = req.params.groupId;
@@ -199,14 +222,17 @@ module.exports = (sequelize, DataTypes) => {
199
222
  const domainId = req.params.domainId;
200
223
  const postId = req.params.postId;
201
224
  const image = await Image.findByPk(imageId);
225
+ const removeByUserIdOnly = Boolean(req.query.removeByUserIdOnly);
226
+ const requestUserId = req.user?.id;
227
+ const isOwnedByRequestUser = image && requestUserId !== undefined && image.user_id === requestUserId;
202
228
  let removed = false;
229
+ let remainingCollectionAssociations;
203
230
  if (image) {
204
- if (req.query.removeByUserIdOnly) {
205
- if (image.user_id === req.user.id) {
206
- image.deleted = true;
207
- await image.save();
208
- removed = true;
209
- }
231
+ if (removeByUserIdOnly && !isOwnedByRequestUser) {
232
+ log.error("Could not remove image from collection not same user", {
233
+ imageId,
234
+ userId: requestUserId,
235
+ });
210
236
  }
211
237
  else if (groupId) {
212
238
  const group = await sequelize.models.Group.findByPk(groupId);
@@ -296,6 +322,14 @@ module.exports = (sequelize, DataTypes) => {
296
322
  });
297
323
  if (postWithImage?.PostHeaderImages?.length) {
298
324
  await post.removePostHeaderImage(image);
325
+ const remainingHeaderImageCount = typeof post.countPostHeaderImages === "function"
326
+ ? await post.countPostHeaderImages()
327
+ : null;
328
+ if (remainingHeaderImageCount === 0 &&
329
+ post.cover_media_type === "image") {
330
+ post.cover_media_type = "none";
331
+ await post.save();
332
+ }
299
333
  removed = true;
300
334
  }
301
335
  else if (postWithImage?.PostImages?.length) {
@@ -308,30 +342,35 @@ module.exports = (sequelize, DataTypes) => {
308
342
  }
309
343
  }
310
344
  }
345
+ if (!removed && removeByUserIdOnly && isOwnedByRequestUser) {
346
+ remainingCollectionAssociations = await hasRemainingCollectionAssociations(image.id);
347
+ removed = !remainingCollectionAssociations;
348
+ }
311
349
  if (removed) {
350
+ if (remainingCollectionAssociations === undefined) {
351
+ remainingCollectionAssociations = await hasRemainingCollectionAssociations(image.id);
352
+ }
353
+ if (remainingCollectionAssociations) {
354
+ log.info("Detached shared image from collection", { imageId: image.id });
355
+ return res
356
+ .status(200)
357
+ .json({ message: "Image removed from collection" });
358
+ }
312
359
  image.deleted = true;
313
360
  await image.save();
314
- import("../services/llms/imageGeneration/s3Service.js").then(async ({ S3Service }) => {
315
- try {
316
- const mediaManager = new S3Service(process.env.CLOUDFLARE_API_KEY, process.env.CLOUDFLARE_ZONE_ID);
317
- await mediaManager.deleteMediaFormatsUrls(image.formats ? JSON.parse(image.formats) : []);
318
- }
319
- catch (error) {
320
- log.warn("Best-effort image cleanup failed", {
321
- imageId: image.id,
322
- error,
323
- });
324
- }
325
- log.info("Deleted image", { imageId: image.id });
326
- res.status(200).json({ message: "Image removed from collection" });
327
- }).catch((error) => {
328
- log.warn("Best-effort image cleanup import failed", {
361
+ try {
362
+ const { S3Service } = await import("../services/llms/imageGeneration/s3Service.js");
363
+ const mediaManager = new S3Service(process.env.CLOUDFLARE_API_KEY, process.env.CLOUDFLARE_ZONE_ID);
364
+ await mediaManager.deleteMediaFormatsUrls(image.formats ? JSON.parse(image.formats) : []);
365
+ }
366
+ catch (error) {
367
+ log.warn("Best-effort image cleanup failed", {
329
368
  imageId: image.id,
330
369
  error,
331
370
  });
332
- log.info("Deleted image", { imageId: image.id });
333
- res.status(200).json({ message: "Image removed from collection" });
334
- });
371
+ }
372
+ log.info("Deleted image", { imageId: image.id });
373
+ return res.status(200).json({ message: "Image removed from collection" });
335
374
  }
336
375
  else {
337
376
  log.error("Could not remove image from collection");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yrpri/api",
3
- "version": "9.0.221",
3
+ "version": "9.0.222",
4
4
  "license": "MIT",
5
5
  "author": "Robert Bjarnason & Citizens Foundation",
6
6
  "repository": {
@@ -25,7 +25,7 @@
25
25
  "@google-cloud/vertexai": "^1.10.0",
26
26
  "@google-cloud/vision": "^5.3.5",
27
27
  "@node-saml/passport-saml": "^5.1.0",
28
- "@policysynth/agents": "^1.3.173",
28
+ "@policysynth/agents": "^1.3.174",
29
29
  "async": "^3.2.6",
30
30
  "authorized": "^1.0.0",
31
31
  "aws-sdk": "^2.1693.0",
@@ -2,6 +2,49 @@ import AWS from "aws-sdk";
2
2
  import fs from "fs";
3
3
  import axios from "axios";
4
4
  import log from "../../../utils/loggerTs.js";
5
+ const normalizeEndpointHost = (endpoint) => {
6
+ if (!endpoint) {
7
+ return null;
8
+ }
9
+ try {
10
+ const normalizedUrl = new URL(endpoint.includes("://") ? endpoint : `https://${endpoint}`);
11
+ return {
12
+ host: normalizedUrl.host,
13
+ hostname: normalizedUrl.hostname,
14
+ };
15
+ }
16
+ catch {
17
+ return {
18
+ host: endpoint,
19
+ hostname: endpoint,
20
+ };
21
+ }
22
+ };
23
+ const getBucketFromVirtualHost = (parsedUrl, normalizedEndpoint) => {
24
+ if (!normalizedEndpoint) {
25
+ return null;
26
+ }
27
+ const hostSuffix = `.${normalizedEndpoint.host}`;
28
+ const hostnameSuffix = `.${normalizedEndpoint.hostname}`;
29
+ if (parsedUrl.host.endsWith(hostSuffix)) {
30
+ return parsedUrl.host.slice(0, -hostSuffix.length) || null;
31
+ }
32
+ if (parsedUrl.hostname.endsWith(hostnameSuffix)) {
33
+ return parsedUrl.hostname.slice(0, -hostnameSuffix.length) || null;
34
+ }
35
+ return null;
36
+ };
37
+ const getAwsBucketFromHostname = (hostname) => {
38
+ const s3MarkerIndex = hostname.indexOf(".s3.");
39
+ if (s3MarkerIndex > 0) {
40
+ return hostname.slice(0, s3MarkerIndex) || null;
41
+ }
42
+ const legacyS3MarkerIndex = hostname.indexOf(".s3-");
43
+ if (legacyS3MarkerIndex > 0) {
44
+ return hostname.slice(0, legacyS3MarkerIndex) || null;
45
+ }
46
+ return null;
47
+ };
5
48
  export class S3Service {
6
49
  constructor(cloudflareApiKey, cloudflareZoneId) {
7
50
  this.cloudflareApiKey = cloudflareApiKey;
@@ -91,21 +134,25 @@ export class S3Service {
91
134
  let bucket, key;
92
135
  const cfImageProxyDomain = process.env.CLOUDFLARE_IMAGE_PROXY_DOMAIN;
93
136
  const s3Bucket = process.env.S3_BUCKET;
137
+ const normalizedEndpoint = normalizeEndpointHost(process.env.S3_ENDPOINT);
94
138
  const parsedUrl = new URL(imageUrl);
95
139
  if (cfImageProxyDomain && imageUrl.includes(cfImageProxyDomain)) {
96
140
  const [, ...pathParts] = parsedUrl.pathname.split("/");
97
141
  bucket = s3Bucket;
98
142
  key = pathParts.join("/");
99
143
  }
100
- else if (parsedUrl.hostname === process.env.S3_ENDPOINT) {
144
+ else if (normalizedEndpoint &&
145
+ (parsedUrl.hostname === normalizedEndpoint.hostname ||
146
+ parsedUrl.host === normalizedEndpoint.host)) {
101
147
  const [, maybeBucket, ...pathParts] = parsedUrl.pathname.split("/");
102
148
  bucket = process.env.MINIO_ROOT_USER ? maybeBucket || s3Bucket : s3Bucket;
103
149
  key = process.env.MINIO_ROOT_USER ? pathParts.join("/") : parsedUrl.pathname.slice(1);
104
150
  }
105
151
  else {
106
- const hostParts = parsedUrl.hostname.split(".");
107
- if (hostParts.length >= 4 && hostParts[1] === "s3") {
108
- bucket = hostParts[0];
152
+ const virtualHostBucket = getBucketFromVirtualHost(parsedUrl, normalizedEndpoint) ||
153
+ getAwsBucketFromHostname(parsedUrl.hostname);
154
+ if (virtualHostBucket) {
155
+ bucket = virtualHostBucket;
109
156
  key = parsedUrl.pathname.replace(/^\/+/, "");
110
157
  }
111
158
  }
@@ -0,0 +1,373 @@
1
+ "use strict";
2
+ const test = require("node:test");
3
+ const assert = require("node:assert/strict");
4
+ const Module = require("node:module");
5
+ const imageModelPath = require.resolve("../models/image.cjs");
6
+ const loggerPath = require.resolve("../utils/logger.cjs");
7
+ const injectMockModule = (modulePath, moduleExports) => {
8
+ const mockModule = new Module(modulePath);
9
+ mockModule.filename = modulePath;
10
+ mockModule.loaded = true;
11
+ mockModule.exports = moduleExports;
12
+ require.cache[modulePath] = mockModule;
13
+ };
14
+ const loadImageModelFactory = () => {
15
+ const originalLoggerModule = require.cache[loggerPath];
16
+ const originalImageModelModule = require.cache[imageModelPath];
17
+ injectMockModule(loggerPath, {
18
+ error() { },
19
+ info() { },
20
+ warn() { },
21
+ });
22
+ delete require.cache[imageModelPath];
23
+ return {
24
+ createImageModel: require(imageModelPath),
25
+ restore() {
26
+ delete require.cache[imageModelPath];
27
+ if (originalLoggerModule) {
28
+ require.cache[loggerPath] = originalLoggerModule;
29
+ }
30
+ else {
31
+ delete require.cache[loggerPath];
32
+ }
33
+ if (originalImageModelModule) {
34
+ require.cache[imageModelPath] = originalImageModelModule;
35
+ }
36
+ },
37
+ };
38
+ };
39
+ const createDataTypesStub = () => new Proxy({}, {
40
+ get() {
41
+ return {};
42
+ },
43
+ });
44
+ const createResponse = () => {
45
+ return {
46
+ body: null,
47
+ statusCode: null,
48
+ status(code) {
49
+ this.statusCode = code;
50
+ return this;
51
+ },
52
+ json(body) {
53
+ this.body = body;
54
+ return this;
55
+ },
56
+ };
57
+ };
58
+ const setupImageModel = ({ imageRecord, postWithImage, groupWithImage, remainingAssociations, remainingPostHeaderImageCount = 0, createImageModel, associations, }) => {
59
+ const post = {
60
+ cover_media_type: undefined,
61
+ saveCallCount: 0,
62
+ removedHeaderImages: [],
63
+ removedPostImages: [],
64
+ removedUserImages: [],
65
+ async save() {
66
+ this.saveCallCount += 1;
67
+ },
68
+ async removePostHeaderImage(image) {
69
+ this.removedHeaderImages.push(image.id);
70
+ },
71
+ async countPostHeaderImages() {
72
+ return remainingPostHeaderImageCount;
73
+ },
74
+ async removePostImage(image) {
75
+ this.removedPostImages.push(image.id);
76
+ },
77
+ async removePostUserImage(image) {
78
+ this.removedUserImages.push(image.id);
79
+ },
80
+ };
81
+ const group = {
82
+ removedLogoImages: [],
83
+ async removeGroupLogoImage(image) {
84
+ this.removedLogoImages.push(image.id);
85
+ },
86
+ };
87
+ const sequelize = {
88
+ define() {
89
+ return {};
90
+ },
91
+ models: {
92
+ Group: {
93
+ async findByPk() {
94
+ return groupWithImage !== undefined ? group : null;
95
+ },
96
+ async findOne() {
97
+ return groupWithImage;
98
+ },
99
+ },
100
+ Post: {
101
+ async findByPk() {
102
+ return post;
103
+ },
104
+ async findOne() {
105
+ return postWithImage;
106
+ },
107
+ },
108
+ },
109
+ };
110
+ const Image = createImageModel(sequelize, createDataTypesStub());
111
+ sequelize.models.Image = Image;
112
+ Image.associations =
113
+ associations || {
114
+ PostImages: { associationType: "BelongsToMany" },
115
+ PostHeaderImages: { associationType: "BelongsToMany" },
116
+ PostUserImages: { associationType: "BelongsToMany" },
117
+ };
118
+ Image.findByPk = async (_imageId, options) => {
119
+ if (options?.include) {
120
+ return remainingAssociations;
121
+ }
122
+ return imageRecord;
123
+ };
124
+ return { Image, post, group };
125
+ };
126
+ test("removeImageFromCollection detaches shared post header images without deleting the image", async (t) => {
127
+ const { createImageModel, restore } = loadImageModelFactory();
128
+ t.after(restore);
129
+ const imageRecord = {
130
+ id: 42,
131
+ deleted: false,
132
+ saveCallCount: 0,
133
+ async save() {
134
+ this.saveCallCount += 1;
135
+ },
136
+ };
137
+ const { Image, post } = setupImageModel({
138
+ createImageModel,
139
+ imageRecord,
140
+ postWithImage: {
141
+ PostHeaderImages: [{ id: 42 }],
142
+ PostImages: [],
143
+ PostUserImages: [],
144
+ },
145
+ remainingAssociations: {
146
+ PostImages: [],
147
+ PostHeaderImages: [{ id: 42 }],
148
+ PostUserImages: [],
149
+ },
150
+ });
151
+ const res = createResponse();
152
+ await Image.removeImageFromCollection({
153
+ params: {
154
+ imageId: "42",
155
+ postId: "10",
156
+ },
157
+ query: {},
158
+ }, res);
159
+ assert.deepEqual(post.removedHeaderImages, [42]);
160
+ assert.equal(imageRecord.deleted, false);
161
+ assert.equal(imageRecord.saveCallCount, 0);
162
+ assert.equal(res.statusCode, 200);
163
+ assert.deepEqual(res.body, { message: "Image removed from collection" });
164
+ });
165
+ test("removeImageFromCollection still deletes unshared post images", async (t) => {
166
+ const { createImageModel, restore } = loadImageModelFactory();
167
+ t.after(restore);
168
+ const imageRecord = {
169
+ id: 99,
170
+ deleted: false,
171
+ saveCallCount: 0,
172
+ async save() {
173
+ this.saveCallCount += 1;
174
+ },
175
+ };
176
+ const { Image, post } = setupImageModel({
177
+ createImageModel,
178
+ imageRecord,
179
+ postWithImage: {
180
+ PostHeaderImages: [],
181
+ PostImages: [{ id: 99 }],
182
+ PostUserImages: [],
183
+ },
184
+ remainingAssociations: {
185
+ PostImages: [],
186
+ PostHeaderImages: [],
187
+ PostUserImages: [],
188
+ },
189
+ });
190
+ const res = createResponse();
191
+ await Image.removeImageFromCollection({
192
+ params: {
193
+ imageId: "99",
194
+ postId: "10",
195
+ },
196
+ query: {},
197
+ }, res);
198
+ assert.deepEqual(post.removedPostImages, [99]);
199
+ assert.equal(imageRecord.deleted, true);
200
+ assert.equal(imageRecord.saveCallCount, 1);
201
+ assert.equal(res.statusCode, 200);
202
+ assert.deepEqual(res.body, { message: "Image removed from collection" });
203
+ });
204
+ test("removeByUserIdOnly detaches the current collection before preserving shared images", async (t) => {
205
+ const { createImageModel, restore } = loadImageModelFactory();
206
+ t.after(restore);
207
+ const imageRecord = {
208
+ id: 77,
209
+ user_id: 850,
210
+ deleted: false,
211
+ saveCallCount: 0,
212
+ async save() {
213
+ this.saveCallCount += 1;
214
+ },
215
+ };
216
+ const { Image, group } = setupImageModel({
217
+ createImageModel,
218
+ imageRecord,
219
+ groupWithImage: {
220
+ GroupLogoImages: [{ id: 77 }],
221
+ },
222
+ remainingAssociations: {
223
+ GroupLogoImages: [{ id: 77 }],
224
+ CommunityLogoImages: [],
225
+ },
226
+ associations: {
227
+ GroupLogoImages: { associationType: "BelongsToMany" },
228
+ CommunityLogoImages: { associationType: "BelongsToMany" },
229
+ },
230
+ });
231
+ const res = createResponse();
232
+ await Image.removeImageFromCollection({
233
+ params: {
234
+ groupId: "55",
235
+ imageId: "77",
236
+ },
237
+ query: {
238
+ removeByUserIdOnly: "true",
239
+ },
240
+ user: {
241
+ id: 850,
242
+ },
243
+ }, res);
244
+ assert.deepEqual(group.removedLogoImages, [77]);
245
+ assert.equal(imageRecord.deleted, false);
246
+ assert.equal(imageRecord.saveCallCount, 0);
247
+ assert.equal(res.statusCode, 200);
248
+ assert.deepEqual(res.body, { message: "Image removed from collection" });
249
+ });
250
+ test("removeByUserIdOnly directly deletes unattached uploads owned by the current user", async (t) => {
251
+ const { createImageModel, restore } = loadImageModelFactory();
252
+ t.after(restore);
253
+ const imageRecord = {
254
+ id: 88,
255
+ user_id: 850,
256
+ deleted: false,
257
+ saveCallCount: 0,
258
+ async save() {
259
+ this.saveCallCount += 1;
260
+ },
261
+ };
262
+ const { Image, group } = setupImageModel({
263
+ createImageModel,
264
+ imageRecord,
265
+ groupWithImage: null,
266
+ remainingAssociations: {
267
+ GroupLogoImages: [],
268
+ },
269
+ associations: {
270
+ GroupLogoImages: { associationType: "BelongsToMany" },
271
+ },
272
+ });
273
+ const res = createResponse();
274
+ await Image.removeImageFromCollection({
275
+ params: {
276
+ groupId: "55",
277
+ imageId: "88",
278
+ },
279
+ query: {
280
+ removeByUserIdOnly: "true",
281
+ },
282
+ user: {
283
+ id: 850,
284
+ },
285
+ }, res);
286
+ assert.deepEqual(group.removedLogoImages, []);
287
+ assert.equal(imageRecord.deleted, true);
288
+ assert.equal(imageRecord.saveCallCount, 1);
289
+ assert.equal(res.statusCode, 200);
290
+ assert.deepEqual(res.body, { message: "Image removed from collection" });
291
+ });
292
+ test("removing the only post header image resets cover_media_type", async (t) => {
293
+ const { createImageModel, restore } = loadImageModelFactory();
294
+ t.after(restore);
295
+ const imageRecord = {
296
+ id: 123,
297
+ deleted: false,
298
+ saveCallCount: 0,
299
+ async save() {
300
+ this.saveCallCount += 1;
301
+ },
302
+ };
303
+ const { Image, post } = setupImageModel({
304
+ createImageModel,
305
+ imageRecord,
306
+ postWithImage: {
307
+ PostHeaderImages: [{ id: 123 }],
308
+ PostImages: [],
309
+ PostUserImages: [],
310
+ },
311
+ remainingAssociations: {
312
+ PostImages: [],
313
+ PostHeaderImages: [],
314
+ PostUserImages: [],
315
+ },
316
+ remainingPostHeaderImageCount: 0,
317
+ });
318
+ post.cover_media_type = "image";
319
+ const res = createResponse();
320
+ await Image.removeImageFromCollection({
321
+ params: {
322
+ imageId: "123",
323
+ postId: "10",
324
+ },
325
+ query: {},
326
+ }, res);
327
+ assert.deepEqual(post.removedHeaderImages, [123]);
328
+ assert.equal(post.cover_media_type, "none");
329
+ assert.equal(post.saveCallCount, 1);
330
+ assert.equal(res.statusCode, 200);
331
+ assert.deepEqual(res.body, { message: "Image removed from collection" });
332
+ });
333
+ test("removing one post header image keeps cover_media_type when others remain", async (t) => {
334
+ const { createImageModel, restore } = loadImageModelFactory();
335
+ t.after(restore);
336
+ const imageRecord = {
337
+ id: 124,
338
+ deleted: false,
339
+ saveCallCount: 0,
340
+ async save() {
341
+ this.saveCallCount += 1;
342
+ },
343
+ };
344
+ const { Image, post } = setupImageModel({
345
+ createImageModel,
346
+ imageRecord,
347
+ postWithImage: {
348
+ PostHeaderImages: [{ id: 124 }],
349
+ PostImages: [],
350
+ PostUserImages: [],
351
+ },
352
+ remainingAssociations: {
353
+ PostImages: [],
354
+ PostHeaderImages: [{ id: 999 }],
355
+ PostUserImages: [],
356
+ },
357
+ remainingPostHeaderImageCount: 1,
358
+ });
359
+ post.cover_media_type = "image";
360
+ const res = createResponse();
361
+ await Image.removeImageFromCollection({
362
+ params: {
363
+ imageId: "124",
364
+ postId: "10",
365
+ },
366
+ query: {},
367
+ }, res);
368
+ assert.deepEqual(post.removedHeaderImages, [124]);
369
+ assert.equal(post.cover_media_type, "image");
370
+ assert.equal(post.saveCallCount, 0);
371
+ assert.equal(res.statusCode, 200);
372
+ assert.deepEqual(res.body, { message: "Image removed from collection" });
373
+ });
@@ -0,0 +1 @@
1
+ export {};