orbitx-core-models 11.178.0 → 11.180.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.
Files changed (59) hide show
  1. package/dist/activity/recorder.d.ts +2 -2
  2. package/dist/activity/recorder.d.ts.map +1 -1
  3. package/dist/activity/recorder.js +4 -7
  4. package/dist/constants/ticketCategories.d.ts +41 -20
  5. package/dist/constants/ticketCategories.d.ts.map +1 -1
  6. package/dist/constants/ticketCategories.js +212 -53
  7. package/dist/helpers/chatAttachments.d.ts +4 -3
  8. package/dist/helpers/chatAttachments.d.ts.map +1 -1
  9. package/dist/helpers/chatAttachments.js +7 -7
  10. package/dist/helpers/emailTemplates.d.ts.map +1 -1
  11. package/dist/helpers/emailTemplates.js +4 -5
  12. package/dist/helpers/ownTickets.d.ts +3 -1
  13. package/dist/helpers/ownTickets.d.ts.map +1 -1
  14. package/dist/helpers/ownTickets.js +30 -11
  15. package/dist/helpers/privateFiles.d.ts +14 -0
  16. package/dist/helpers/privateFiles.d.ts.map +1 -0
  17. package/dist/helpers/privateFiles.js +45 -0
  18. package/dist/helpers/requestInfo.d.ts +47 -0
  19. package/dist/helpers/requestInfo.d.ts.map +1 -0
  20. package/dist/helpers/requestInfo.js +156 -0
  21. package/dist/helpers/s3Storage.d.ts +24 -2
  22. package/dist/helpers/s3Storage.d.ts.map +1 -1
  23. package/dist/helpers/s3Storage.js +73 -5
  24. package/dist/helpers/sessions.d.ts +51 -3
  25. package/dist/helpers/sessions.d.ts.map +1 -1
  26. package/dist/helpers/sessions.js +144 -11
  27. package/dist/index.d.ts +8 -5
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +16 -2
  30. package/dist/interfaces/company.interface.d.ts +2 -0
  31. package/dist/interfaces/company.interface.d.ts.map +1 -1
  32. package/dist/interfaces/session.interface.d.ts +2 -0
  33. package/dist/interfaces/session.interface.d.ts.map +1 -1
  34. package/dist/interfaces/subscription.interface.d.ts +2 -0
  35. package/dist/interfaces/subscription.interface.d.ts.map +1 -1
  36. package/dist/interfaces/ticket.interface.d.ts +30 -26
  37. package/dist/interfaces/ticket.interface.d.ts.map +1 -1
  38. package/dist/interfaces/ticket.interface.js +28 -4
  39. package/dist/interfaces/user.interface.d.ts +4 -0
  40. package/dist/interfaces/user.interface.d.ts.map +1 -1
  41. package/dist/interfaces/vertical.interface.d.ts +1 -1
  42. package/dist/interfaces/vertical.interface.d.ts.map +1 -1
  43. package/dist/models/company.model.d.ts.map +1 -1
  44. package/dist/models/company.model.js +3 -0
  45. package/dist/models/session.model.d.ts.map +1 -1
  46. package/dist/models/session.model.js +4 -0
  47. package/dist/models/subscription.model.d.ts.map +1 -1
  48. package/dist/models/subscription.model.js +4 -0
  49. package/dist/models/ticket.model.d.ts.map +1 -1
  50. package/dist/models/ticket.model.js +24 -38
  51. package/dist/models/user.model.d.ts.map +1 -1
  52. package/dist/models/user.model.js +7 -0
  53. package/dist/models/vertical.model.js +1 -1
  54. package/dist/routing/ticketRouting.d.ts +11 -1
  55. package/dist/routing/ticketRouting.d.ts.map +1 -1
  56. package/dist/routing/ticketRouting.js +82 -33
  57. package/dist/utils/ticketSanitize.d.ts.map +1 -1
  58. package/dist/utils/ticketSanitize.js +4 -0
  59. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"ownTickets.d.ts","sourceRoot":"","sources":["../../src/helpers/ownTickets.ts"],"names":[],"mappings":"AAuBA,wBAAgB,0BAA0B,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,YAAY,EAAE,EAAE;IAC9H,SAAS,EAAE,SAAS,GAAG,SAAS,CAAC;IACjC,eAAe,EAAE,GAAG,EAAE,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IAClE,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,GAAG,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,GAAG,CAAC;QAAC,QAAQ,EAAE,GAAG,CAAC;QAAC,QAAQ,EAAE,GAAG,CAAA;KAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;CACvH;;;;;;EAoJA"}
1
+ {"version":3,"file":"ownTickets.d.ts","sourceRoot":"","sources":["../../src/helpers/ownTickets.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAmB,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAMhE,wBAAgB,0BAA0B,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,YAAY,EAAE,QAAQ,EAAE,EAAE;IACxI,SAAS,EAAE,SAAS,GAAG,SAAS,CAAC;IACjC,eAAe,EAAE,GAAG,EAAE,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IAClE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,GAAG,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,GAAG,CAAC;QAAC,QAAQ,EAAE,GAAG,CAAC;QAAC,QAAQ,EAAE,GAAG,CAAA;KAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;CACvH;;;;;;EAiKA"}
@@ -12,6 +12,8 @@ exports.createOwnTicketsController = createOwnTicketsController;
12
12
  * createOwnTicketsController({ ownerType: "Partner", categoryOptions: PARTNER_TICKET_CATEGORY_OPTIONS,
13
13
  * fallbackAuthorName: "Partner", uploadAttachments })
14
14
  * uploadAttachments(files, ownerId) stores the request's files and resolves the ticket attachment list.
15
+ * signFile(url) turns a stored attachment URL into a signed link (attachments are private files); without it the
16
+ * stored URLs are sent as they are.
15
17
  */
16
18
  const mongoose_1 = __importDefault(require("mongoose"));
17
19
  const ticket_model_1 = require("../models/ticket.model");
@@ -22,10 +24,11 @@ const ticketRouting_1 = require("../routing/ticketRouting");
22
24
  const ticketSanitize_1 = require("../utils/ticketSanitize");
23
25
  const errors_1 = require("./errors");
24
26
  const _http_1 = require("./_http");
27
+ const privateFiles_1 = require("./privateFiles");
25
28
  // Categories where a client disputes one lead/chat: the only ones with a real LeadDispute behind them.
26
29
  // "chat-dispute" is a legacy value (no longer offered) kept so an old ticket behaves as it always did.
27
30
  const LEAD_DISPUTE_CATEGORIES = ["chat-dispute", "dispute-billable-lead"];
28
- function createOwnTicketsController({ ownerType, categoryOptions, fallbackAuthorName, uploadAttachments, raiseDispute }) {
31
+ function createOwnTicketsController({ ownerType, categoryOptions, fallbackAuthorName, uploadAttachments, raiseDispute, signFile }) {
29
32
  const TicketModel = ticket_model_1.Ticket;
30
33
  const UpdateModel = update_model_1.Update;
31
34
  const OverrideModel = ticketCategoryOverride_model_1.TicketSubCategoryOverride;
@@ -55,20 +58,27 @@ function createOwnTicketsController({ ownerType, categoryOptions, fallbackAuthor
55
58
  console.error(`[Ticket ${ticket._id}] Failed to write assignment update:`, err.message);
56
59
  }
57
60
  }
58
- // Routes a new ticket the same way Ops's own triage does. Routing is a convenience: an unrouted ticket just waits
59
- // for Ops to pick it up.
61
+ // Routes a new ticket to the team its category belongs to (billing / scripting / the account's Account Manager /
62
+ // general support -- see routing/ticketRouting.ts). Routing is a convenience: an unrouted ticket just waits for Ops
63
+ // to pick it up (and its new-ticket alert goes to every Support Manager).
60
64
  async function routeNewTicket(ticket) {
61
65
  try {
62
66
  const routing = await (0, ticketRouting_1.resolveTicketRouting)(ticket);
63
- if (!routing.assigneeId && routing.followerIds.length === 0)
64
- return;
65
- const update = { $push: {} };
67
+ const update = { $set: {}, $push: {} };
68
+ if (routing.team)
69
+ update.$set.team = routing.team;
66
70
  if (routing.assigneeId) {
67
- update.$set = { assigneeId: routing.assigneeId };
71
+ update.$set.assigneeId = routing.assigneeId;
68
72
  update.$push.activityLog = { action: "assigned", actorName: "Routing Engine", to: String(routing.assigneeId), createdAt: new Date() };
69
73
  }
70
74
  if (routing.followerIds.length)
71
75
  update.$push.followerIds = { $each: routing.followerIds };
76
+ if (!Object.keys(update.$push).length)
77
+ delete update.$push;
78
+ if (!Object.keys(update.$set).length)
79
+ delete update.$set;
80
+ if (!update.$set && !update.$push)
81
+ return;
72
82
  await TicketModel.updateOne({ _id: ticket._id }, update);
73
83
  await notifyAssignee(routing.assigneeId, ticket);
74
84
  }
@@ -120,7 +130,11 @@ function createOwnTicketsController({ ownerType, categoryOptions, fallbackAuthor
120
130
  }
121
131
  }
122
132
  await routeNewTicket(ticket);
123
- res.status(_http_1.HTTP.CREATED).json({ success: true, message: "Ticket submitted successfully", data: { ticket, disputeWarning } });
133
+ res.status(_http_1.HTTP.CREATED).json({
134
+ success: true,
135
+ message: "Ticket submitted successfully",
136
+ data: { ticket: await (0, privateFiles_1.signTicketFiles)(ticket, signFile), disputeWarning },
137
+ });
124
138
  });
125
139
  const withPeople = (query) => query.populate("requestedBy", "name email").populate("resolvedBy", "name email");
126
140
  const getMyTickets = (0, _http_1.catchAsync)(async (req, res) => {
@@ -132,13 +146,14 @@ function createOwnTicketsController({ ownerType, categoryOptions, fallbackAuthor
132
146
  queryObj.category = category;
133
147
  const tickets = await withPeople(TicketModel.find(queryObj)).sort({ createdAt: -1 }).lean();
134
148
  tickets.forEach(ticketSanitize_1.sanitizeTicketForRequester);
135
- res.status(_http_1.HTTP.OK).json({ success: true, results: tickets.length, data: { tickets } });
149
+ const signed = await Promise.all(tickets.map((t) => (0, privateFiles_1.signTicketFiles)(t, signFile)));
150
+ res.status(_http_1.HTTP.OK).json({ success: true, results: signed.length, data: { tickets: signed } });
136
151
  });
137
152
  const getMyTicketById = (0, _http_1.catchAsync)(async (req, res, next) => {
138
153
  const ticket = await withPeople(TicketModel.findOne({ _id: req.params.id, ownerType, ownerId: req.user.owner })).lean();
139
154
  if (!ticket)
140
155
  return next(new errors_1.AppError("Ticket not found", _http_1.HTTP.NOT_FOUND));
141
- res.status(_http_1.HTTP.OK).json({ success: true, data: { ticket: (0, ticketSanitize_1.sanitizeTicketForRequester)(ticket) } });
156
+ res.status(_http_1.HTTP.OK).json({ success: true, data: { ticket: await (0, privateFiles_1.signTicketFiles)((0, ticketSanitize_1.sanitizeTicketForRequester)(ticket), signFile) } });
142
157
  });
143
158
  // A reply from the requester: always visible to them (they can never post an internal note).
144
159
  // Body: { body }. File field: attachments (optional, several).
@@ -163,7 +178,11 @@ function createOwnTicketsController({ ownerType, categoryOptions, fallbackAuthor
163
178
  };
164
179
  await TicketModel.updateOne({ _id: ticket._id }, { $push: { messages: message }, $set: { updatedAt: new Date() } });
165
180
  const updated = await withPeople(TicketModel.findById(ticket._id)).lean();
166
- res.status(_http_1.HTTP.CREATED).json({ success: true, message: "Message added", data: { ticket: (0, ticketSanitize_1.sanitizeTicketForRequester)(updated) } });
181
+ res.status(_http_1.HTTP.CREATED).json({
182
+ success: true,
183
+ message: "Message added",
184
+ data: { ticket: await (0, privateFiles_1.signTicketFiles)((0, ticketSanitize_1.sanitizeTicketForRequester)(updated), signFile) },
185
+ });
167
186
  });
168
187
  return { getTicketCategories, createTicket, getMyTickets, getMyTicketById, addTicketMessage };
169
188
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Swapping stored file URLs for signed links in what a backend sends out, for the private file folders
3
+ * (PRIVATE_FILE_FOLDERS in s3Storage.ts). Each backend passes its own signer, usually
4
+ * const signFile = (url, seconds) => storage.signUrl(url, seconds);
5
+ * Public files and anything that isn't an S3 link pass through unchanged, so these are safe to call on any data.
6
+ */
7
+ export type SignFile = (url?: string | null, seconds?: number) => Promise<string | null | undefined>;
8
+ /** A ticket (plain object) with every attachment link signed: the ticket's own and each message's. */
9
+ export declare function signTicketFiles<T = any>(ticket: T, signFile?: SignFile, seconds?: number): Promise<T>;
10
+ /** Chat messages (plain objects) with each mediaUrl signed. */
11
+ export declare function signMessagesMedia<T = any>(messages: T[], signFile?: SignFile, seconds?: number): Promise<T[]>;
12
+ /** A list of rows with an attachment link in `field` (e.g. request attachments), each signed. */
13
+ export declare function signFileList<T = any>(rows: T[] | undefined, signFile?: SignFile, field?: string, seconds?: number): Promise<T[] | undefined>;
14
+ //# sourceMappingURL=privateFiles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"privateFiles.d.ts","sourceRoot":"","sources":["../../src/helpers/privateFiles.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;AAOrG,sGAAsG;AACtG,wBAAsB,eAAe,CAAC,CAAC,GAAG,GAAG,EAAE,MAAM,EAAE,CAAC,EAAE,QAAQ,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAe3G;AAED,+DAA+D;AAC/D,wBAAsB,iBAAiB,CAAC,CAAC,GAAG,GAAG,EAAE,QAAQ,EAAE,CAAC,EAAE,EAAE,QAAQ,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CASnH;AAED,iGAAiG;AACjG,wBAAsB,YAAY,CAAC,CAAC,GAAG,GAAG,EAAE,IAAI,EAAE,CAAC,EAAE,GAAG,SAAS,EAAE,QAAQ,CAAC,EAAE,QAAQ,EAAE,KAAK,SAAQ,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,EAAE,GAAG,SAAS,CAAC,CAGjJ"}
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ /**
3
+ * Swapping stored file URLs for signed links in what a backend sends out, for the private file folders
4
+ * (PRIVATE_FILE_FOLDERS in s3Storage.ts). Each backend passes its own signer, usually
5
+ * const signFile = (url, seconds) => storage.signUrl(url, seconds);
6
+ * Public files and anything that isn't an S3 link pass through unchanged, so these are safe to call on any data.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.signTicketFiles = signTicketFiles;
10
+ exports.signMessagesMedia = signMessagesMedia;
11
+ exports.signFileList = signFileList;
12
+ const signList = async (list, signFile, field, seconds) => Array.isArray(list)
13
+ ? Promise.all(list.map(async (item) => (item && item[field] ? { ...item, [field]: await signFile(item[field], seconds) } : item)))
14
+ : list;
15
+ /** A ticket (plain object) with every attachment link signed: the ticket's own and each message's. */
16
+ async function signTicketFiles(ticket, signFile, seconds) {
17
+ const t = ticket;
18
+ if (!t || !signFile)
19
+ return ticket;
20
+ const plain = typeof t.toObject === "function" ? t.toObject() : t;
21
+ const [attachments, messages] = await Promise.all([
22
+ signList(plain.attachments, signFile, "url", seconds),
23
+ Array.isArray(plain.messages)
24
+ ? Promise.all(plain.messages.map(async (m) => m && Array.isArray(m.attachments) ? { ...m, attachments: await signList(m.attachments, signFile, "url", seconds) } : m))
25
+ : plain.messages,
26
+ ]);
27
+ return { ...plain, attachments, messages };
28
+ }
29
+ /** Chat messages (plain objects) with each mediaUrl signed. */
30
+ async function signMessagesMedia(messages, signFile, seconds) {
31
+ if (!signFile || !Array.isArray(messages))
32
+ return messages;
33
+ return Promise.all(messages.map(async (m) => {
34
+ if (!(m === null || m === void 0 ? void 0 : m.mediaUrl))
35
+ return m;
36
+ const plain = typeof m.toObject === "function" ? m.toObject() : m;
37
+ return { ...plain, mediaUrl: await signFile(plain.mediaUrl, seconds) };
38
+ }));
39
+ }
40
+ /** A list of rows with an attachment link in `field` (e.g. request attachments), each signed. */
41
+ async function signFileList(rows, signFile, field = "url", seconds) {
42
+ if (!signFile)
43
+ return rows;
44
+ return signList(rows, signFile, field, seconds);
45
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Who is signing in, as far as the request can tell, for every backend's sign-in sessions and the activity log:
3
+ * clientIp(req) the visitor's real IP address
4
+ * requestLocation(req) "Lahore, Pakistan" from Cloudflare's visitor-location headers
5
+ * describeDevice(userAgent, hints) a readable device ("Windows PC", "iPhone", "Samsung SM-S911B"), browser and OS
6
+ * isPrivateIp(ip) true for proxy / Docker / local addresses, which say nothing about the visitor
7
+ *
8
+ * Requests reach the backends through nginx (and Cloudflare on proxied hosts), so the socket address is the proxy's
9
+ * (inside Docker, the bridge gateway "::ffff:172.18.0.1"), never the visitor's.
10
+ */
11
+ type Req = any;
12
+ /** "::ffff:1.2.3.4" (an IPv4 address written the IPv6 way) -> "1.2.3.4". */
13
+ export declare function normalizeIp(ip?: string | null): string | undefined;
14
+ /** Loopback, private-network and link-local addresses: what a proxy or Docker hands on, not a visitor's address. */
15
+ export declare function isPrivateIp(ip?: string | null): boolean;
16
+ /**
17
+ * The visitor's IP address, in order of trust:
18
+ * CF-Connecting-IP set by Cloudflare on proxied hosts (client.orbitxpro.com)
19
+ * X-Real-IP set by nginx from the connecting address (on a Cloudflare-proxied host this is Cloudflare's)
20
+ * X-Forwarded-For first entry
21
+ * the socket only right when nothing sits in front of the app
22
+ */
23
+ export declare function clientIp(req: Req): string | undefined;
24
+ /**
25
+ * Where the request came from, from Cloudflare's visitor-location headers: "Lahore, Pakistan", or just "Pakistan".
26
+ * Only Cloudflare-proxied hosts get these. The country comes with every proxied request; the city and region only
27
+ * when the zone's "Add visitor location headers" Managed Transform is on. Nothing (undefined) otherwise.
28
+ */
29
+ export declare function requestLocation(req: Req): string | undefined;
30
+ /** What the browser can add beyond the user agent (Chrome's `navigator.userAgentData`): the Android phone model and
31
+ * the real OS version (the only way to tell Windows 11 from Windows 10). */
32
+ export type DeviceHints = {
33
+ model?: string;
34
+ platformVersion?: string;
35
+ };
36
+ export type DeviceDescription = {
37
+ deviceName: string;
38
+ browser: string;
39
+ os: string;
40
+ };
41
+ /** Reads the user agent (plus the browser's hints when sent) into a device name, browser and OS. iPhones only ever
42
+ * say "iPhone" (no model), and laptops never say their brand: the person can name the device themselves. */
43
+ export declare function describeDevice(userAgent?: string | null, hints?: DeviceHints): DeviceDescription;
44
+ /** A name the person gave a device: trimmed, at most 40 characters, "" to clear it. */
45
+ export declare function cleanDeviceName(value: unknown): string;
46
+ export {};
47
+ //# sourceMappingURL=requestInfo.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"requestInfo.d.ts","sourceRoot":"","sources":["../../src/helpers/requestInfo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,KAAK,GAAG,GAAG,GAAG,CAAC;AAOf,4EAA4E;AAC5E,wBAAgB,WAAW,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAKlE;AAED,oHAAoH;AACpH,wBAAgB,WAAW,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CASvD;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,GAAG,SAAS,CAKrD;AAiBD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,GAAG,SAAS,CAS5D;AAED;6EAC6E;AAC7E,MAAM,MAAM,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvE,MAAM,MAAM,iBAAiB,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAyBpF;6GAC6G;AAC7G,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,GAAE,WAAgB,GAAG,iBAAiB,CA8CpG;AAED,uFAAuF;AACvF,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEtD"}
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ /**
3
+ * Who is signing in, as far as the request can tell, for every backend's sign-in sessions and the activity log:
4
+ * clientIp(req) the visitor's real IP address
5
+ * requestLocation(req) "Lahore, Pakistan" from Cloudflare's visitor-location headers
6
+ * describeDevice(userAgent, hints) a readable device ("Windows PC", "iPhone", "Samsung SM-S911B"), browser and OS
7
+ * isPrivateIp(ip) true for proxy / Docker / local addresses, which say nothing about the visitor
8
+ *
9
+ * Requests reach the backends through nginx (and Cloudflare on proxied hosts), so the socket address is the proxy's
10
+ * (inside Docker, the bridge gateway "::ffff:172.18.0.1"), never the visitor's.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.normalizeIp = normalizeIp;
14
+ exports.isPrivateIp = isPrivateIp;
15
+ exports.clientIp = clientIp;
16
+ exports.requestLocation = requestLocation;
17
+ exports.describeDevice = describeDevice;
18
+ exports.cleanDeviceName = cleanDeviceName;
19
+ const header = (req, name) => {
20
+ var _a, _b;
21
+ const value = (_a = req === null || req === void 0 ? void 0 : req.headers) === null || _a === void 0 ? void 0 : _a[name];
22
+ return String((_b = (Array.isArray(value) ? value[0] : value)) !== null && _b !== void 0 ? _b : "").trim();
23
+ };
24
+ /** "::ffff:1.2.3.4" (an IPv4 address written the IPv6 way) -> "1.2.3.4". */
25
+ function normalizeIp(ip) {
26
+ if (!ip)
27
+ return undefined;
28
+ let value = String(ip).trim();
29
+ if (/^::ffff:\d+\.\d+\.\d+\.\d+$/i.test(value))
30
+ value = value.slice(7);
31
+ return value || undefined;
32
+ }
33
+ /** Loopback, private-network and link-local addresses: what a proxy or Docker hands on, not a visitor's address. */
34
+ function isPrivateIp(ip) {
35
+ const value = normalizeIp(ip);
36
+ if (!value)
37
+ return true;
38
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(value)) {
39
+ const [a, b] = value.split(".").map(Number);
40
+ return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
41
+ }
42
+ const lower = value.toLowerCase();
43
+ return lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd") || lower.startsWith("fe80:");
44
+ }
45
+ /**
46
+ * The visitor's IP address, in order of trust:
47
+ * CF-Connecting-IP set by Cloudflare on proxied hosts (client.orbitxpro.com)
48
+ * X-Real-IP set by nginx from the connecting address (on a Cloudflare-proxied host this is Cloudflare's)
49
+ * X-Forwarded-For first entry
50
+ * the socket only right when nothing sits in front of the app
51
+ */
52
+ function clientIp(req) {
53
+ var _a;
54
+ const forwarded = header(req, "x-forwarded-for").split(",")[0].trim();
55
+ return normalizeIp(header(req, "cf-connecting-ip") || header(req, "x-real-ip") || forwarded || ((_a = req === null || req === void 0 ? void 0 : req.socket) === null || _a === void 0 ? void 0 : _a.remoteAddress) || (req === null || req === void 0 ? void 0 : req.ip));
56
+ }
57
+ // Cloudflare sends non-English city names as raw UTF-8 bytes, which Node reads as Latin-1.
58
+ function fixEncoding(value) {
59
+ if (!/[\u0080-ÿ]/.test(value))
60
+ return value;
61
+ const decoded = Buffer.from(value, "latin1").toString("utf8");
62
+ return decoded.includes("�") ? value : decoded;
63
+ }
64
+ function countryName(code) {
65
+ try {
66
+ return new Intl.DisplayNames(["en"], { type: "region" }).of(code) || code;
67
+ }
68
+ catch {
69
+ return code;
70
+ }
71
+ }
72
+ /**
73
+ * Where the request came from, from Cloudflare's visitor-location headers: "Lahore, Pakistan", or just "Pakistan".
74
+ * Only Cloudflare-proxied hosts get these. The country comes with every proxied request; the city and region only
75
+ * when the zone's "Add visitor location headers" Managed Transform is on. Nothing (undefined) otherwise.
76
+ */
77
+ function requestLocation(req) {
78
+ const code = header(req, "cf-ipcountry").toUpperCase();
79
+ // XX = unknown, T1 = Tor.
80
+ const country = /^[A-Z]{2}$/.test(code) && code !== "XX" && code !== "T1" ? countryName(code) : "";
81
+ const city = fixEncoding(header(req, "cf-ipcity"));
82
+ const region = fixEncoding(header(req, "cf-region"));
83
+ const place = city || region;
84
+ const parts = [place, country].filter(Boolean);
85
+ return parts.length ? parts.join(", ").slice(0, 120) : undefined;
86
+ }
87
+ const clean = (value, max = 60) => typeof value === "string" ? value.replace(/[^\w .,()+\-/]/g, "").trim().slice(0, max) : "";
88
+ function describeBrowser(ua) {
89
+ const pick = (name, re) => {
90
+ const m = ua.match(re);
91
+ return m ? `${name} ${m[1]}` : null;
92
+ };
93
+ return (pick("Edge", /\bEdg(?:e|A|iOS)?\/(\d+)/) ||
94
+ pick("Opera", /\b(?:OPR|OPX)\/(\d+)/) ||
95
+ pick("Samsung Internet", /\bSamsungBrowser\/(\d+)/) ||
96
+ pick("Firefox", /\b(?:Firefox|FxiOS)\/(\d+)/) ||
97
+ pick("Chrome", /\bCriOS\/(\d+)/) ||
98
+ (!/Chromium/.test(ua) ? pick("Chrome", /\bChrome\/(\d+)/) : null) ||
99
+ pick("Chromium", /\bChromium\/(\d+)/) ||
100
+ (/Safari\//.test(ua) ? pick("Safari", /\bVersion\/(\d+(?:\.\d+)?)/) || "Safari" : null) ||
101
+ "Unknown browser");
102
+ }
103
+ const WINDOWS_NT = { "6.3": "Windows 8.1", "6.2": "Windows 8", "6.1": "Windows 7" };
104
+ /** Reads the user agent (plus the browser's hints when sent) into a device name, browser and OS. iPhones only ever
105
+ * say "iPhone" (no model), and laptops never say their brand: the person can name the device themselves. */
106
+ function describeDevice(userAgent, hints = {}) {
107
+ var _a, _b;
108
+ const ua = String(userAgent || "");
109
+ const browser = describeBrowser(ua);
110
+ const model = clean(hints.model);
111
+ const platformMajor = parseInt(clean(hints.platformVersion, 20), 10);
112
+ const apple = ua.match(/\b(iPhone|iPad|iPod)\b/);
113
+ if (apple) {
114
+ const version = ua.match(/OS (\d+)[_.](\d+)/);
115
+ const osName = apple[1] === "iPad" ? "iPadOS" : "iOS";
116
+ return { deviceName: apple[1], browser, os: version ? `${osName} ${version[1]}.${version[2]}` : osName };
117
+ }
118
+ const android = ua.match(/Android (\d+(?:\.\d+)?)/);
119
+ if (android || /Android/.test(ua)) {
120
+ // Older user agents name the model ("Android 10; SM-G973F Build/..."); current Chrome sends "K" instead and only
121
+ // tells the model through the hints.
122
+ const uaModel = (_b = (_a = ua.match(/Android [\d.]+; ([^;)]+?)(?: Build\/|\))/)) === null || _a === void 0 ? void 0 : _a[1]) === null || _b === void 0 ? void 0 : _b.trim();
123
+ const found = (model || (uaModel && uaModel !== "K" ? clean(uaModel) : "")).replace(/^samsung\s+/i, "");
124
+ const deviceName = found
125
+ ? /^SM-/i.test(found)
126
+ ? `Samsung ${found}`
127
+ : found
128
+ : /Mobile/.test(ua)
129
+ ? "Android phone"
130
+ : "Android tablet";
131
+ // Current Chrome freezes the version in the user agent ("Android 10"); the hint has the real one.
132
+ const version = Number.isFinite(platformMajor) && platformMajor > 0 ? String(platformMajor) : android === null || android === void 0 ? void 0 : android[1];
133
+ return { deviceName, browser, os: version ? `Android ${version}` : "Android" };
134
+ }
135
+ const windows = ua.match(/Windows NT (\d+\.\d+)/);
136
+ if (windows) {
137
+ let os = WINDOWS_NT[windows[1]] || "Windows";
138
+ if (windows[1] === "10.0") {
139
+ // Windows 11 reports itself as NT 10.0; only the hint's platform version (13 and up) tells them apart.
140
+ os = Number.isFinite(platformMajor) ? (platformMajor >= 13 ? "Windows 11" : "Windows 10") : "Windows 10/11";
141
+ }
142
+ return { deviceName: "Windows PC", browser, os };
143
+ }
144
+ // Browsers freeze the macOS version in the user agent (10.15.7), so it isn't shown.
145
+ if (/Macintosh|Mac OS X/.test(ua))
146
+ return { deviceName: "Mac", browser, os: "macOS" };
147
+ if (/CrOS/.test(ua))
148
+ return { deviceName: "Chromebook", browser, os: "ChromeOS" };
149
+ if (/Linux/.test(ua))
150
+ return { deviceName: "Linux PC", browser, os: "Linux" };
151
+ return { deviceName: "Unknown device", browser, os: "Unknown OS" };
152
+ }
153
+ /** A name the person gave a device: trimmed, at most 40 characters, "" to clear it. */
154
+ function cleanDeviceName(value) {
155
+ return typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, 40) : "";
156
+ }
@@ -2,8 +2,13 @@
2
2
  * The dashboards' S3 file storage: file naming, upload, delete and public links, shared by every backend.
3
3
  * A backend passes the AWS SDK it already depends on and its settings, then keeps its own wrapper for the
4
4
  * file types it accepts and the messages it shows:
5
- * const storage = createS3Storage({ sdk: require("@aws-sdk/client-s3"), config: () => env.aws });
5
+ * const storage = createS3Storage({ sdk: require("@aws-sdk/client-s3"), presigner: require("@aws-sdk/s3-request-presigner"), config: () => env.aws });
6
6
  * const { key, publicUrl } = await storage.put({ buffer, mimetype, originalname, folder });
7
+ * const link = await storage.signUrl(storedUrlOrKey); // a private file's short-lived link
8
+ *
9
+ * Private files: ticket and request attachments and chat files (folders in PRIVATE_FILE_FOLDERS) are not publicly
10
+ * readable in the bucket. They're stored as before (key + the plain S3 URL), and every response that shows one
11
+ * swaps the stored URL for a signed link that works for a limited time (FILE_LINK_SECONDS).
7
12
  */
8
13
  export declare const IMAGE_MIME_TYPES: Set<string>;
9
14
  /** Profile photos: no SVG, since these are served as public links. */
@@ -11,14 +16,28 @@ export declare const PHOTO_MIME_TYPES: Set<string>;
11
16
  /** What ticket attachments accept: images and documents (PDF, Word, Excel, PowerPoint, text, zip, OpenDocument). */
12
17
  export declare const TICKET_ATTACHMENT_MIME_TYPES: Set<string>;
13
18
  export declare const sanitizeFileName: (fileName: string) => string;
19
+ /**
20
+ * Folders whose files are private (may hold customers' or visitors' personal details): only reachable through a
21
+ * signed link. Everything else (profile photos, agent aliases, review images) stays public, because website
22
+ * visitors and emails show them directly. The bucket policy must match this list.
23
+ */
24
+ export declare const PRIVATE_FILE_FOLDERS: string[];
25
+ /** How long a signed link works: long enough for a dashboard page left open all day; emails use the S3 maximum. */
26
+ export declare const FILE_LINK_SECONDS: {
27
+ readonly dashboard: number;
28
+ readonly email: number;
29
+ };
30
+ /** True for a key in one of the private folders. */
31
+ export declare const isPrivateFileKey: (key?: string | null) => boolean;
14
32
  export type S3Settings = {
15
33
  region?: string;
16
34
  bucketName?: string;
17
35
  accessKeyId?: string;
18
36
  secretAccessKey?: string;
19
37
  };
20
- export declare function createS3Storage({ sdk, config }: {
38
+ export declare function createS3Storage({ sdk, presigner, config }: {
21
39
  sdk: any;
40
+ presigner?: any;
22
41
  config: () => S3Settings;
23
42
  }): {
24
43
  put: ({ buffer, mimetype, originalname, folder, fallbackName }: {
@@ -34,5 +53,8 @@ export declare function createS3Storage({ sdk, config }: {
34
53
  remove: (key?: string | null) => Promise<void>;
35
54
  publicUrlFor: (key: string) => string;
36
55
  isConfigured: () => boolean;
56
+ keyFromUrl: (url?: string | null) => string | null;
57
+ signedUrl: (key: string, seconds?: number) => Promise<string>;
58
+ signUrl: (urlOrKey?: string | null, seconds?: number) => Promise<string | null | undefined>;
37
59
  };
38
60
  //# sourceMappingURL=s3Storage.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"s3Storage.d.ts","sourceRoot":"","sources":["../../src/helpers/s3Storage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,eAAO,MAAM,gBAAgB,aAAmF,CAAC;AAEjH,sEAAsE;AACtE,eAAO,MAAM,gBAAgB,aAAkE,CAAC;AAEhG,oHAAoH;AACpH,eAAO,MAAM,4BAA4B,aAiBvC,CAAC;AAEH,eAAO,MAAM,gBAAgB,GAAI,UAAU,MAAM,KAAG,MAA0D,CAAC;AAE/G,MAAM,MAAM,UAAU,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAElH,wBAAgB,eAAe,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,MAAM,UAAU,CAAA;CAAE;oEA2BC;QACpF,MAAM,EAAE,GAAG,CAAC;QACZ,QAAQ,EAAE,MAAM,CAAC;QACjB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,MAAM,EAAE,MAAM,CAAC;QACf,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB,KAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;mBAOnB,MAAM,GAAG,IAAI,KAAG,OAAO,CAAC,IAAI,CAAC;wBAnB9B,MAAM;;EAyBlC"}
1
+ {"version":3,"file":"s3Storage.d.ts","sourceRoot":"","sources":["../../src/helpers/s3Storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,eAAO,MAAM,gBAAgB,aAAmF,CAAC;AAEjH,sEAAsE;AACtE,eAAO,MAAM,gBAAgB,aAAkE,CAAC;AAEhG,oHAAoH;AACpH,eAAO,MAAM,4BAA4B,aAiBvC,CAAC;AAEH,eAAO,MAAM,gBAAgB,GAAI,UAAU,MAAM,KAAG,MAA0D,CAAC;AAE/G;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,UAA+D,CAAC;AAEjG,mHAAmH;AACnH,eAAO,MAAM,iBAAiB;;;CAAgE,CAAC;AAE/F,oDAAoD;AACpD,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,GAAG,IAAI,KAAG,OACiB,CAAC;AAEzE,MAAM,MAAM,UAAU,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAElH,wBAAgB,eAAe,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,SAAS,CAAC,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,MAAM,UAAU,CAAA;CAAE;oEA2B3B;QACpF,MAAM,EAAE,GAAG,CAAC;QACZ,QAAQ,EAAE,MAAM,CAAC;QACjB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,MAAM,EAAE,MAAM,CAAC;QACf,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB,KAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;mBAOnB,MAAM,GAAG,IAAI,KAAG,OAAO,CAAC,IAAI,CAAC;wBAnB9B,MAAM;;uBA4BP,MAAM,GAAG,IAAI,KAAG,MAAM,GAAG,IAAI;qBAmBzB,MAAM,YAAW,MAAM,KAAiC,OAAO,CAAC,MAAM,CAAC;yBAWnE,MAAM,GAAG,IAAI,YAAW,MAAM,KAAiC,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;EAapI"}
@@ -3,11 +3,16 @@
3
3
  * The dashboards' S3 file storage: file naming, upload, delete and public links, shared by every backend.
4
4
  * A backend passes the AWS SDK it already depends on and its settings, then keeps its own wrapper for the
5
5
  * file types it accepts and the messages it shows:
6
- * const storage = createS3Storage({ sdk: require("@aws-sdk/client-s3"), config: () => env.aws });
6
+ * const storage = createS3Storage({ sdk: require("@aws-sdk/client-s3"), presigner: require("@aws-sdk/s3-request-presigner"), config: () => env.aws });
7
7
  * const { key, publicUrl } = await storage.put({ buffer, mimetype, originalname, folder });
8
+ * const link = await storage.signUrl(storedUrlOrKey); // a private file's short-lived link
9
+ *
10
+ * Private files: ticket and request attachments and chat files (folders in PRIVATE_FILE_FOLDERS) are not publicly
11
+ * readable in the bucket. They're stored as before (key + the plain S3 URL), and every response that shows one
12
+ * swaps the stored URL for a signed link that works for a limited time (FILE_LINK_SECONDS).
8
13
  */
9
14
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.sanitizeFileName = exports.TICKET_ATTACHMENT_MIME_TYPES = exports.PHOTO_MIME_TYPES = exports.IMAGE_MIME_TYPES = void 0;
15
+ exports.isPrivateFileKey = exports.FILE_LINK_SECONDS = exports.PRIVATE_FILE_FOLDERS = exports.sanitizeFileName = exports.TICKET_ATTACHMENT_MIME_TYPES = exports.PHOTO_MIME_TYPES = exports.IMAGE_MIME_TYPES = void 0;
11
16
  exports.createS3Storage = createS3Storage;
12
17
  const crypto_1 = require("crypto");
13
18
  exports.IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"]);
@@ -34,8 +39,19 @@ exports.TICKET_ATTACHMENT_MIME_TYPES = new Set([
34
39
  ]);
35
40
  const sanitizeFileName = (fileName) => fileName.trim().replace(/[^a-zA-Z0-9._-]/g, "-");
36
41
  exports.sanitizeFileName = sanitizeFileName;
37
- function createS3Storage({ sdk, config }) {
38
- const { S3Client, PutObjectCommand, DeleteObjectCommand } = sdk;
42
+ /**
43
+ * Folders whose files are private (may hold customers' or visitors' personal details): only reachable through a
44
+ * signed link. Everything else (profile photos, agent aliases, review images) stays public, because website
45
+ * visitors and emails show them directly. The bucket policy must match this list.
46
+ */
47
+ exports.PRIVATE_FILE_FOLDERS = ["tickets/", "requests/", "partner/tickets/", "chat-media/"];
48
+ /** How long a signed link works: long enough for a dashboard page left open all day; emails use the S3 maximum. */
49
+ exports.FILE_LINK_SECONDS = { dashboard: 12 * 60 * 60, email: 7 * 24 * 60 * 60 };
50
+ /** True for a key in one of the private folders. */
51
+ const isPrivateFileKey = (key) => !!key && exports.PRIVATE_FILE_FOLDERS.some((folder) => key.startsWith(folder));
52
+ exports.isPrivateFileKey = isPrivateFileKey;
53
+ function createS3Storage({ sdk, presigner, config }) {
54
+ const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } = sdk;
39
55
  let client;
40
56
  let clientFor = "";
41
57
  const s3 = () => {
@@ -69,5 +85,57 @@ function createS3Storage({ sdk, config }) {
69
85
  return;
70
86
  await s3().send(new DeleteObjectCommand({ Bucket: config().bucketName, Key: key }));
71
87
  }
72
- return { put, remove, publicUrlFor, isConfigured };
88
+ /**
89
+ * The key of a file in this bucket, from its stored S3 URL ("https://<bucket>.s3.<region>.amazonaws.com/<key>",
90
+ * the path-style form, or an already signed link). Null for anything else (another site, a data: URL).
91
+ */
92
+ function keyFromUrl(url) {
93
+ if (!url || typeof url !== "string")
94
+ return null;
95
+ const { bucketName } = config();
96
+ let parsed;
97
+ try {
98
+ parsed = new URL(url);
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ const host = parsed.hostname.toLowerCase();
104
+ if (!host.endsWith(".amazonaws.com") || !bucketName)
105
+ return null;
106
+ const path = decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
107
+ if (host.startsWith(`${bucketName.toLowerCase()}.s3`))
108
+ return path || null;
109
+ // Path-style: s3.<region>.amazonaws.com/<bucket>/<key>
110
+ if (path.startsWith(`${bucketName}/`))
111
+ return path.slice(bucketName.length + 1) || null;
112
+ return null;
113
+ }
114
+ /** A link to a stored file that works for `seconds` (S3 allows at most 7 days). */
115
+ async function signedUrl(key, seconds = exports.FILE_LINK_SECONDS.dashboard) {
116
+ if (!(presigner === null || presigner === void 0 ? void 0 : presigner.getSignedUrl) || !GetObjectCommand)
117
+ throw new Error("createS3Storage: pass presigner to sign links");
118
+ const expiresIn = Math.max(60, Math.min(seconds, exports.FILE_LINK_SECONDS.email));
119
+ return presigner.getSignedUrl(s3(), new GetObjectCommand({ Bucket: config().bucketName, Key: key }), { expiresIn });
120
+ }
121
+ /**
122
+ * What a response should show for a stored file: a signed link when it's a private file of this bucket, else the
123
+ * stored value unchanged (public files, other sites, empty). Never throws: if signing fails it returns the stored
124
+ * value (the file just won't open once the bucket is private, instead of the whole page failing).
125
+ */
126
+ async function signUrl(urlOrKey, seconds = exports.FILE_LINK_SECONDS.dashboard) {
127
+ if (!urlOrKey)
128
+ return urlOrKey;
129
+ const key = /^https?:\/\//i.test(urlOrKey) ? keyFromUrl(urlOrKey) : urlOrKey;
130
+ if (!key || !(0, exports.isPrivateFileKey)(key) || !isConfigured())
131
+ return urlOrKey;
132
+ try {
133
+ return await signedUrl(key, seconds);
134
+ }
135
+ catch (err) {
136
+ console.error("[s3Storage] could not sign a file link:", err === null || err === void 0 ? void 0 : err.message);
137
+ return urlOrKey;
138
+ }
139
+ }
140
+ return { put, remove, publicUrlFor, isConfigured, keyFromUrl, signedUrl, signUrl };
73
141
  }
@@ -9,14 +9,62 @@ export type LoginDevice = {
9
9
  os?: string;
10
10
  userAgent?: string;
11
11
  ip?: string;
12
+ /** From the browser's hints (Chrome's navigator.userAgentData): the Android phone model and the OS version. */
13
+ deviceModel?: string;
14
+ platformVersion?: string;
12
15
  };
13
- /** Stores a new 30-day session for the user and returns its id. */
16
+ /**
17
+ * True when this sign-in comes from a browser the account has never signed in from before, so the person gets a
18
+ * "new sign-in" email. Call it BEFORE createLoginSession (which records the browser). The very first sign-in of an
19
+ * account isn't "new" (there's nothing to compare with), and without a browser id there's nothing to go on.
20
+ * Sessions are never deleted (revoked and expired ones stay), so this sees every browser ever used.
21
+ */
22
+ export declare function isNewSignInDevice(userId: any, fingerprintId?: string | null): Promise<boolean>;
23
+ /**
24
+ * Stores a new 30-day session for the user and returns its id. Pass the request so the session gets the visitor's
25
+ * real IP address and location (read from the Cloudflare / nginx headers) and the real user agent.
26
+ */
14
27
  export declare function createLoginSession(user: {
15
28
  _id: any;
16
- }, { fingerprintId, browser, os, userAgent, ip }: LoginDevice): Promise<string>;
17
- /** The signed-in user's open sessions, and revoking one of them. */
29
+ }, device: LoginDevice, req?: any): Promise<string>;
30
+ /**
31
+ * Marks the session as used just now, so the Sessions screen can tell Active from Idle. At most one small write per
32
+ * session every 5 minutes, never awaited and never failing the request. Ops passes its own session model.
33
+ */
34
+ export declare function touchSession(sessionId?: string | null, SessionModel?: any): void;
35
+ export type SessionView = {
36
+ _id: string;
37
+ sessionId: string;
38
+ /** What to call the device: the person's own name for it, else the detected one ("Windows PC", "iPhone"). */
39
+ deviceName: string;
40
+ /** The detected name, shown under a custom name and used to prefill "Name this device". */
41
+ detectedName: string;
42
+ customName?: string;
43
+ browser?: string;
44
+ os?: string;
45
+ /** Unset when only a proxy's address was recorded (sign-ins before the real-IP fix). */
46
+ ipAddress?: string;
47
+ location?: string;
48
+ isCurrent: boolean;
49
+ status: "active" | "idle";
50
+ lastActiveAt?: Date;
51
+ createdAt?: Date;
52
+ expiresAt?: Date;
53
+ };
54
+ /** One session as the Sessions screens show it. Sessions from before Oct 2026 stored no device name (Client /
55
+ * Partner) or an old "Chrome 129 on Windows 10/11" label (Ops); those are read again from their user agent. */
56
+ export declare function sessionView(session: any, currentSessionId?: string | null): SessionView;
57
+ /** The signed-in user's sessions as the Sessions screen shows them: this device first, then most recently used. */
58
+ export declare function listSessionViews(SessionModel: any, userId: any, currentSessionId?: string | null): Promise<SessionView[]>;
59
+ /** Names one of the user's devices (all of that device's sessions, so later sign-ins keep it); "" clears it. */
60
+ export declare function renameSessionDevice(SessionModel: any, userId: any, id: string, rawName: unknown): Promise<{
61
+ name: string;
62
+ } | null>;
63
+ /** The signed-in user's open sessions, naming a device, and revoking a session. The backend's protect middleware
64
+ * sets req.sessionId (the JWT's session) so "This device" is marked here, not guessed in the browser. */
18
65
  export declare function createSessionHandlers(): {
19
66
  getSessions: (req: any, res: any, next: any) => void;
67
+ renameSession: (req: any, res: any, next: any) => void;
20
68
  revokeSession: (req: any, res: any, next: any) => void;
21
69
  };
22
70
  //# sourceMappingURL=sessions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/helpers/sessions.ts"],"names":[],"mappings":"AAYA,wHAAwH;AACxH,wBAAgB,mBAAmB,IAAI;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAS3E;AAED,MAAM,MAAM,WAAW,GAAG;IAAE,aAAa,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAErH,mEAAmE;AACnE,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAAE,GAAG,EAAE,GAAG,CAAA;CAAE,EAAE,EAAE,aAAa,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAexI;AAED,oEAAoE;AACpE,wBAAgB,qBAAqB;;;EAiBpC"}
1
+ {"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/helpers/sessions.ts"],"names":[],"mappings":"AAiBA,wHAAwH;AACxH,wBAAgB,mBAAmB,IAAI;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAS3E;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,+GAA+G;IAC/G,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAYF;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,GAAG,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAQpG;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAAE,GAAG,EAAE,GAAG,CAAA;CAAE,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,CAAC,EAAE,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,CAoB5G;AAKD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,YAAY,GAAE,GAAa,GAAG,IAAI,CAYzF;AAKD,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,6GAA6G;IAC7G,UAAU,EAAE,MAAM,CAAC;IACnB,2FAA2F;IAC3F,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,wFAAwF;IACxF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,SAAS,CAAC,EAAE,IAAI,CAAC;CAClB,CAAC;AAEF;gHACgH;AAChH,wBAAgB,WAAW,CAAC,OAAO,EAAE,GAAG,EAAE,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,WAAW,CA4BvF;AAED,mHAAmH;AACnH,wBAAsB,gBAAgB,CAAC,YAAY,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAM/H;AAED,gHAAgH;AAChH,wBAAsB,mBAAmB,CAAC,YAAY,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO;;UAQrG;AAED;0GAC0G;AAC1G,wBAAgB,qBAAqB;;;;EA4BpC"}