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.
- package/dist/activity/recorder.d.ts +2 -2
- package/dist/activity/recorder.d.ts.map +1 -1
- package/dist/activity/recorder.js +4 -7
- package/dist/constants/ticketCategories.d.ts +41 -20
- package/dist/constants/ticketCategories.d.ts.map +1 -1
- package/dist/constants/ticketCategories.js +212 -53
- package/dist/helpers/chatAttachments.d.ts +4 -3
- package/dist/helpers/chatAttachments.d.ts.map +1 -1
- package/dist/helpers/chatAttachments.js +7 -7
- package/dist/helpers/emailTemplates.d.ts.map +1 -1
- package/dist/helpers/emailTemplates.js +4 -5
- package/dist/helpers/ownTickets.d.ts +3 -1
- package/dist/helpers/ownTickets.d.ts.map +1 -1
- package/dist/helpers/ownTickets.js +30 -11
- package/dist/helpers/privateFiles.d.ts +14 -0
- package/dist/helpers/privateFiles.d.ts.map +1 -0
- package/dist/helpers/privateFiles.js +45 -0
- package/dist/helpers/requestInfo.d.ts +47 -0
- package/dist/helpers/requestInfo.d.ts.map +1 -0
- package/dist/helpers/requestInfo.js +156 -0
- package/dist/helpers/s3Storage.d.ts +24 -2
- package/dist/helpers/s3Storage.d.ts.map +1 -1
- package/dist/helpers/s3Storage.js +73 -5
- package/dist/helpers/sessions.d.ts +51 -3
- package/dist/helpers/sessions.d.ts.map +1 -1
- package/dist/helpers/sessions.js +144 -11
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/interfaces/company.interface.d.ts +2 -0
- package/dist/interfaces/company.interface.d.ts.map +1 -1
- package/dist/interfaces/session.interface.d.ts +2 -0
- package/dist/interfaces/session.interface.d.ts.map +1 -1
- package/dist/interfaces/subscription.interface.d.ts +2 -0
- package/dist/interfaces/subscription.interface.d.ts.map +1 -1
- package/dist/interfaces/ticket.interface.d.ts +30 -26
- package/dist/interfaces/ticket.interface.d.ts.map +1 -1
- package/dist/interfaces/ticket.interface.js +28 -4
- package/dist/interfaces/user.interface.d.ts +4 -0
- package/dist/interfaces/user.interface.d.ts.map +1 -1
- package/dist/interfaces/vertical.interface.d.ts +1 -1
- package/dist/interfaces/vertical.interface.d.ts.map +1 -1
- package/dist/models/company.model.d.ts.map +1 -1
- package/dist/models/company.model.js +3 -0
- package/dist/models/session.model.d.ts.map +1 -1
- package/dist/models/session.model.js +4 -0
- package/dist/models/subscription.model.d.ts.map +1 -1
- package/dist/models/subscription.model.js +4 -0
- package/dist/models/ticket.model.d.ts.map +1 -1
- package/dist/models/ticket.model.js +24 -38
- package/dist/models/user.model.d.ts.map +1 -1
- package/dist/models/user.model.js +7 -0
- package/dist/models/vertical.model.js +1 -1
- package/dist/routing/ticketRouting.d.ts +11 -1
- package/dist/routing/ticketRouting.d.ts.map +1 -1
- package/dist/routing/ticketRouting.js +82 -33
- package/dist/utils/ticketSanitize.d.ts.map +1 -1
- package/dist/utils/ticketSanitize.js +4 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ownTickets.d.ts","sourceRoot":"","sources":["../../src/helpers/ownTickets.ts"],"names":[],"mappings":"
|
|
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
|
|
59
|
-
//
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
const update = { $set: {}, $push: {} };
|
|
68
|
+
if (routing.team)
|
|
69
|
+
update.$set.team = routing.team;
|
|
66
70
|
if (routing.assigneeId) {
|
|
67
|
-
update.$set =
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
},
|
|
17
|
-
/**
|
|
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":"
|
|
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"}
|