@progressive-development/pd-provider-firebase-functions 1.3.0 → 1.4.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/bin/prepare-functions-deploy.mjs +141 -0
- package/bin/prepare-functions-deploy.spec.mjs +108 -0
- package/dist/auth-trash.js +118 -107
- package/dist/auth.js +30 -23
- package/dist/changes.js +53 -60
- package/dist/counter.js +19 -17
- package/dist/errors.js +41 -40
- package/dist/firestore.js +17 -11
- package/dist/history.js +74 -45
- package/dist/import-templates.js +102 -106
- package/dist/index.js +16 -15
- package/dist/logger.js +11 -8
- package/dist/mail.js +56 -35
- package/dist/response.js +35 -26
- package/dist/script-helpers.js +55 -44
- package/dist/trash.js +105 -72
- package/dist/types.js +10 -9
- package/dist/visits.js +124 -82
- package/package.json +15 -8
package/dist/visits.js
CHANGED
|
@@ -1,105 +1,147 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { getDb } from "./firestore.js";
|
|
2
|
+
import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
//#region src/visits.ts
|
|
5
|
+
var DEVICES = [
|
|
6
|
+
"phone",
|
|
7
|
+
"tablet",
|
|
8
|
+
"desktop"
|
|
9
|
+
];
|
|
10
|
+
/** Deliberately short: this keeps out the loudest noise, it is not a bot list. */
|
|
11
|
+
var BOTS = /bot|crawl|spider|slurp|preview|monitor|headless|lighthouse|curl|wget/i;
|
|
12
|
+
/** Firestore ALREADY_EXISTS — a gRPC status code, not an HTTP one. */
|
|
13
|
+
var ALREADY_EXISTS = 6;
|
|
14
|
+
/**
|
|
15
|
+
* The day as YYYY-MM-DD in the given zone.
|
|
16
|
+
*
|
|
17
|
+
* "sv-SE" is the shortest way to an ISO-shaped date: `toISOString()` would be
|
|
18
|
+
* UTC, and building it by hand from parts is where off-by-one bugs live.
|
|
19
|
+
*/
|
|
8
20
|
function visitDay(now, timeZone) {
|
|
9
|
-
|
|
21
|
+
return new Intl.DateTimeFormat("sv-SE", { timeZone }).format(now);
|
|
10
22
|
}
|
|
23
|
+
/** The stretch of time a visitor key stays the same. */
|
|
11
24
|
function visitPeriod(day, rotate) {
|
|
12
|
-
|
|
25
|
+
return rotate === "month" ? day.slice(0, 7) : day;
|
|
13
26
|
}
|
|
27
|
+
/** The technical key — short enough to read, long enough not to collide. */
|
|
14
28
|
function visitorKey(period, ip, userAgent, secret = "") {
|
|
15
|
-
|
|
29
|
+
return createHash("sha256").update(`${secret}|${period}|${ip}|${userAgent}`).digest("hex").slice(0, 16);
|
|
16
30
|
}
|
|
31
|
+
/** Unknown values are dropped, never guessed and never stored raw. */
|
|
17
32
|
function cleanDevice(value) {
|
|
18
|
-
|
|
33
|
+
return typeof value === "string" && DEVICES.includes(value) ? value : void 0;
|
|
19
34
|
}
|
|
35
|
+
/** A host, nothing else — no path, no query, no room for someone else's data. */
|
|
20
36
|
function cleanReferrer(value) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
if (typeof value !== "string") return void 0;
|
|
38
|
+
const host = value.trim().toLowerCase();
|
|
39
|
+
return /^[a-z0-9.\-:]{1,60}$/.test(host) ? host : void 0;
|
|
24
40
|
}
|
|
25
41
|
function isBot(userAgent) {
|
|
26
|
-
|
|
42
|
+
return BOTS.test(userAgent);
|
|
27
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Browser family and major version.
|
|
46
|
+
*
|
|
47
|
+
* ORDER IS THE WHOLE TRICK, because these strings quote each other: Edge says
|
|
48
|
+
* Edg AND Chrome AND Safari, Chrome says Chrome AND Safari. So the most
|
|
49
|
+
* specific claim wins, and Safari is asked last. On iOS every engine is
|
|
50
|
+
* Safari's, but the browsers name themselves (CriOS, FxiOS) — reported as what
|
|
51
|
+
* the user sees, since that is what the question is about.
|
|
52
|
+
*
|
|
53
|
+
* Unclassified traffic becomes "other" rather than nothing: an unreadable
|
|
54
|
+
* agent is a fact worth seeing, missing data is a different fact.
|
|
55
|
+
*/
|
|
28
56
|
function browserOf(userAgent) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
for (const [browser, pattern] of [
|
|
58
|
+
["Edge", /Edg(?:iOS|A)?\/(\d+)/],
|
|
59
|
+
["Opera", /OPR\/(\d+)/],
|
|
60
|
+
["Samsung Internet", /SamsungBrowser\/(\d+)/],
|
|
61
|
+
["Chrome", /CriOS\/(\d+)/],
|
|
62
|
+
["Firefox", /FxiOS\/(\d+)/],
|
|
63
|
+
["Firefox", /Firefox\/(\d+)/],
|
|
64
|
+
["Chrome", /Chrome\/(\d+)/],
|
|
65
|
+
["Safari", /Version\/(\d+)[\d.]*\s+(?:Mobile\/\S+\s+)?Safari/]
|
|
66
|
+
]) {
|
|
67
|
+
const hit = pattern.exec(userAgent);
|
|
68
|
+
if (hit) return {
|
|
69
|
+
browser,
|
|
70
|
+
version: hit[1]
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { browser: "other" };
|
|
44
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Operating system family, never a version.
|
|
77
|
+
*
|
|
78
|
+
* Android carries "Linux" in its agent and an iPad in desktop mode calls
|
|
79
|
+
* itself a Macintosh — so the order matters here too, and the iPad stays
|
|
80
|
+
* wrong. That is a limit of the source, not something to guess around.
|
|
81
|
+
*/
|
|
45
82
|
function osOf(userAgent) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
83
|
+
if (/iPhone|iPad|iPod/.test(userAgent)) return "iOS";
|
|
84
|
+
if (/Android/.test(userAgent)) return "Android";
|
|
85
|
+
if (/CrOS/.test(userAgent)) return "ChromeOS";
|
|
86
|
+
if (/Windows/.test(userAgent)) return "Windows";
|
|
87
|
+
if (/Macintosh|Mac OS X/.test(userAgent)) return "macOS";
|
|
88
|
+
if (/Linux/.test(userAgent)) return "Linux";
|
|
89
|
+
return "other";
|
|
53
90
|
}
|
|
91
|
+
/** No list means no restriction; an empty match means this call does not count. */
|
|
54
92
|
function originAllowed(origin, allowed) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return typeof origin === "string" && list.includes(origin);
|
|
93
|
+
if (!allowed) return true;
|
|
94
|
+
return typeof origin === "string" && (Array.isArray(allowed) ? allowed : [allowed]).includes(origin);
|
|
58
95
|
}
|
|
96
|
+
/** First entry of x-forwarded-for; the rest are proxies. */
|
|
59
97
|
function clientIp(headers) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return first || void 0;
|
|
98
|
+
const raw = headers["x-forwarded-for"];
|
|
99
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
100
|
+
if (typeof value !== "string") return void 0;
|
|
101
|
+
return value.split(",")[0]?.trim() || void 0;
|
|
65
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Count one visit — or recognise that this visitor was already counted today.
|
|
105
|
+
*
|
|
106
|
+
* Never throws for the ordinary "does not count" cases (wrong origin, bot, no
|
|
107
|
+
* address, already seen): the caller is a fire-and-forget call from a page
|
|
108
|
+
* that must not care.
|
|
109
|
+
*/
|
|
66
110
|
async function countVisit(request, visit = {}, options = {}) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
throw e;
|
|
102
|
-
}
|
|
111
|
+
const headers = request.rawRequest?.headers ?? {};
|
|
112
|
+
const day = visitDay(/* @__PURE__ */ new Date(), options.timeZone ?? "UTC");
|
|
113
|
+
const skip = {
|
|
114
|
+
counted: false,
|
|
115
|
+
day
|
|
116
|
+
};
|
|
117
|
+
if (!originAllowed(headers.origin, options.origin)) return skip;
|
|
118
|
+
const userAgent = typeof headers["user-agent"] === "string" ? headers["user-agent"] : "";
|
|
119
|
+
if (!userAgent || isBot(userAgent)) return skip;
|
|
120
|
+
const ip = clientIp(headers);
|
|
121
|
+
if (!ip) return skip;
|
|
122
|
+
const { browser, version } = browserOf(userAgent);
|
|
123
|
+
const visitor = visitorKey(visitPeriod(day, options.rotate ?? "day"), ip, userAgent, options.secret);
|
|
124
|
+
try {
|
|
125
|
+
await getDb(options.databaseId).collection(options.collection ?? "visits").doc(`${day}_${visitor}`).create({
|
|
126
|
+
day,
|
|
127
|
+
visitor,
|
|
128
|
+
at: FieldValue.serverTimestamp(),
|
|
129
|
+
device: cleanDevice(visit.device),
|
|
130
|
+
standalone: visit.standalone === true,
|
|
131
|
+
referrer: cleanReferrer(visit.referrer),
|
|
132
|
+
browser,
|
|
133
|
+
browserVersion: version,
|
|
134
|
+
os: osOf(userAgent),
|
|
135
|
+
expires: options.retentionDays ? Timestamp.fromMillis(Date.now() + options.retentionDays * 864e5) : void 0
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
counted: true,
|
|
139
|
+
day
|
|
140
|
+
};
|
|
141
|
+
} catch (e) {
|
|
142
|
+
if (e?.code === ALREADY_EXISTS) return skip;
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
103
145
|
}
|
|
104
|
-
|
|
146
|
+
//#endregion
|
|
105
147
|
export { browserOf, cleanDevice, cleanReferrer, clientIp, countVisit, isBot, originAllowed, osOf, visitDay, visitPeriod, visitorKey };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@progressive-development/pd-provider-firebase-functions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Firebase Functions v2 utilities for pd-spa-helper backend",
|
|
5
5
|
"author": "PD Progressive Development",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -11,19 +11,22 @@
|
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./dist/index.js"
|
|
13
13
|
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"pd-prepare-functions-deploy": "./bin/prepare-functions-deploy.mjs"
|
|
16
|
+
},
|
|
14
17
|
"files": [
|
|
15
18
|
"dist/",
|
|
19
|
+
"bin/",
|
|
16
20
|
"package.json",
|
|
17
21
|
"README.md",
|
|
18
22
|
"LICENSE"
|
|
19
23
|
],
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"firebase-functions": "^6.0.0"
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": "22"
|
|
23
26
|
},
|
|
24
27
|
"peerDependencies": {
|
|
25
|
-
"firebase-admin": "
|
|
26
|
-
"firebase-functions": "
|
|
28
|
+
"firebase-admin": ">=12.6.0 <15",
|
|
29
|
+
"firebase-functions": ">=6 <8"
|
|
27
30
|
},
|
|
28
31
|
"keywords": [
|
|
29
32
|
"pd",
|
|
@@ -33,12 +36,16 @@
|
|
|
33
36
|
"functions",
|
|
34
37
|
"backend"
|
|
35
38
|
],
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"firebase-admin": "^14.3.0",
|
|
41
|
+
"firebase-functions": "^7.3.2"
|
|
42
|
+
},
|
|
36
43
|
"scripts": {
|
|
37
44
|
"build": "vite build",
|
|
38
45
|
"clean": "rm -rf dist",
|
|
39
46
|
"clean:all": "rm -rf dist node_modules pnpm-lock.yaml",
|
|
40
|
-
"lint": "eslint
|
|
41
|
-
"format": "eslint
|
|
47
|
+
"lint": "eslint src && prettier \"**/*.ts\" --check --ignore-path ../../.eslintignore",
|
|
48
|
+
"format": "eslint src --fix && prettier \"**/*.ts\" --write --ignore-path ../../.eslintignore",
|
|
42
49
|
"check": "pnpm run lint && pnpm run build",
|
|
43
50
|
"test": "vitest run"
|
|
44
51
|
}
|