@progressive-development/pd-provider-firebase-functions 1.2.0 → 1.3.1
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/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.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -14
- 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.d.ts +135 -0
- package/dist/visits.d.ts.map +1 -0
- package/dist/visits.js +147 -0
- package/dist/visits.spec.d.ts +2 -0
- package/dist/visits.spec.d.ts.map +1 -0
- package/package.json +11 -8
package/dist/script-helpers.js
CHANGED
|
@@ -1,52 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { getDb } from "./firestore.js";
|
|
2
|
+
import { cert, getApps, initializeApp } from "firebase-admin/app";
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
//#region src/script-helpers.ts
|
|
5
|
+
/**
|
|
6
|
+
* Shared helpers for Firebase Admin scripts.
|
|
7
|
+
*
|
|
8
|
+
* Provides common boilerplate: logging, service-account validation,
|
|
9
|
+
* Firebase init, Firestore access, and dry-run handling.
|
|
10
|
+
*/
|
|
11
|
+
var LOG_PREFIX = {
|
|
12
|
+
info: "i ",
|
|
13
|
+
success: "OK",
|
|
14
|
+
error: "X ",
|
|
15
|
+
warn: "! "
|
|
10
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Print a prefixed log message to console.
|
|
19
|
+
*/
|
|
11
20
|
function scriptLog(message, type = "info") {
|
|
12
|
-
|
|
21
|
+
console.log(`${LOG_PREFIX[type]} ${message}`);
|
|
13
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Initialize Firebase Admin SDK if not already initialized.
|
|
25
|
+
*/
|
|
14
26
|
function initFirebaseAdmin(serviceAccountPath) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
databaseURL: `https://${serviceAccount.projectId}.firebaseio.com`
|
|
22
|
-
});
|
|
27
|
+
if (getApps().length > 0) return;
|
|
28
|
+
const serviceAccount = JSON.parse(readFileSync(serviceAccountPath, "utf-8"));
|
|
29
|
+
initializeApp({
|
|
30
|
+
credential: cert(serviceAccount),
|
|
31
|
+
databaseURL: `https://${serviceAccount.projectId}.firebaseio.com`
|
|
32
|
+
});
|
|
23
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Set up a script context: validates paths, initializes Firebase,
|
|
36
|
+
* returns a ready-to-use Firestore instance.
|
|
37
|
+
*
|
|
38
|
+
* Prints title, connection info, and dry-run banner.
|
|
39
|
+
* Calls `process.exit(1)` if the service account key is missing.
|
|
40
|
+
*/
|
|
24
41
|
function createScriptContext(config) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
scriptLog(`Firestore: ${databaseId ?? "(default)"}`);
|
|
45
|
-
if (dryRun) {
|
|
46
|
-
scriptLog("DRY RUN - No changes will be made", "warn");
|
|
47
|
-
}
|
|
48
|
-
console.log("-".repeat(50));
|
|
49
|
-
return { db, dryRun };
|
|
42
|
+
const { title, serviceAccountPath, databaseId, dryRun = false } = config;
|
|
43
|
+
console.log(`\n${title}\n`);
|
|
44
|
+
console.log("-".repeat(50));
|
|
45
|
+
if (!existsSync(serviceAccountPath)) {
|
|
46
|
+
scriptLog("Service Account Key not found!", "error");
|
|
47
|
+
console.log(`\n Expected: ${serviceAccountPath}`);
|
|
48
|
+
console.log(`\n Download from Firebase Console:`);
|
|
49
|
+
console.log(` Project Settings > Service Accounts > Generate new private key\n`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
initFirebaseAdmin(serviceAccountPath);
|
|
53
|
+
const db = getDb(databaseId);
|
|
54
|
+
scriptLog(`Firestore: ${databaseId ?? "(default)"}`);
|
|
55
|
+
if (dryRun) scriptLog("DRY RUN - No changes will be made", "warn");
|
|
56
|
+
console.log("-".repeat(50));
|
|
57
|
+
return {
|
|
58
|
+
db,
|
|
59
|
+
dryRun
|
|
60
|
+
};
|
|
50
61
|
}
|
|
51
|
-
|
|
62
|
+
//#endregion
|
|
52
63
|
export { createScriptContext, initFirebaseAdmin, scriptLog };
|
package/dist/trash.js
CHANGED
|
@@ -1,83 +1,116 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getDb } from
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { NotFoundError } from "./errors.js";
|
|
2
|
+
import { getDb } from "./firestore.js";
|
|
3
|
+
import { Timestamp } from "firebase-admin/firestore";
|
|
4
|
+
//#region src/trash.ts
|
|
5
|
+
var TRASH_COLLECTION = "_trash";
|
|
6
|
+
/**
|
|
7
|
+
* Move a document to trash.
|
|
8
|
+
*
|
|
9
|
+
* Atomically:
|
|
10
|
+
* 1. Reads the original document
|
|
11
|
+
* 2. Creates a trash entry with metadata
|
|
12
|
+
* 3. Deletes the original document
|
|
13
|
+
*
|
|
14
|
+
* @param collection - The collection path (e.g., "djRequests")
|
|
15
|
+
* @param documentId - The document ID to trash
|
|
16
|
+
* @param deletedBy - UID of the deleting user, or a { uid, name } stamp
|
|
17
|
+
* (the name is denormalized into the trash item)
|
|
18
|
+
* @param objectType - Type identifier for filtering (e.g., "djRequest")
|
|
19
|
+
* @param databaseId - Optional named database
|
|
20
|
+
* @returns The created trash item
|
|
21
|
+
* @throws NotFoundError if document doesn't exist
|
|
22
|
+
*/
|
|
6
23
|
async function moveToTrash(collection, documentId, deletedBy, objectType, databaseId) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
id: trashRef.id,
|
|
31
|
-
...trashData
|
|
32
|
-
};
|
|
33
|
-
});
|
|
24
|
+
const db = getDb(databaseId);
|
|
25
|
+
const originalRef = db.collection(collection).doc(documentId);
|
|
26
|
+
const trashRef = db.collection(TRASH_COLLECTION).doc();
|
|
27
|
+
return db.runTransaction(async (transaction) => {
|
|
28
|
+
const originalDoc = await transaction.get(originalRef);
|
|
29
|
+
if (!originalDoc.exists) throw new NotFoundError(`Document ${collection}/${documentId} not found`);
|
|
30
|
+
const originalData = originalDoc.data();
|
|
31
|
+
const trashData = {
|
|
32
|
+
originalId: documentId,
|
|
33
|
+
originalCollection: collection,
|
|
34
|
+
objectType,
|
|
35
|
+
deletedAt: Timestamp.now(),
|
|
36
|
+
deletedBy: typeof deletedBy === "string" ? deletedBy : deletedBy.uid,
|
|
37
|
+
...typeof deletedBy === "object" && { deletedByName: deletedBy.name },
|
|
38
|
+
data: originalData
|
|
39
|
+
};
|
|
40
|
+
transaction.set(trashRef, trashData);
|
|
41
|
+
transaction.delete(originalRef);
|
|
42
|
+
return {
|
|
43
|
+
id: trashRef.id,
|
|
44
|
+
...trashData
|
|
45
|
+
};
|
|
46
|
+
});
|
|
34
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Restore a document from trash to its original location.
|
|
50
|
+
*
|
|
51
|
+
* Atomically:
|
|
52
|
+
* 1. Reads the trash entry
|
|
53
|
+
* 2. Recreates the original document
|
|
54
|
+
* 3. Deletes the trash entry
|
|
55
|
+
*
|
|
56
|
+
* @param trashItemId - The trash document ID
|
|
57
|
+
* @param databaseId - Optional named database
|
|
58
|
+
* @throws NotFoundError if trash item doesn't exist
|
|
59
|
+
*/
|
|
35
60
|
async function restoreFromTrash(trashItemId, databaseId) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
transaction.delete(trashRef);
|
|
47
|
-
});
|
|
61
|
+
const db = getDb(databaseId);
|
|
62
|
+
const trashRef = db.collection(TRASH_COLLECTION).doc(trashItemId);
|
|
63
|
+
await db.runTransaction(async (transaction) => {
|
|
64
|
+
const trashDoc = await transaction.get(trashRef);
|
|
65
|
+
if (!trashDoc.exists) throw new NotFoundError(`Trash item ${trashItemId} not found`);
|
|
66
|
+
const trashData = trashDoc.data();
|
|
67
|
+
const originalRef = db.collection(trashData.originalCollection).doc(trashData.originalId);
|
|
68
|
+
transaction.set(originalRef, trashData.data);
|
|
69
|
+
transaction.delete(trashRef);
|
|
70
|
+
});
|
|
48
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Permanently delete a document from trash.
|
|
74
|
+
*
|
|
75
|
+
* @param trashItemId - The trash document ID
|
|
76
|
+
* @param databaseId - Optional named database
|
|
77
|
+
* @throws NotFoundError if trash item doesn't exist
|
|
78
|
+
*/
|
|
49
79
|
async function permanentDelete(trashItemId, databaseId) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!trashDoc.exists) {
|
|
54
|
-
throw new NotFoundError(`Trash item ${trashItemId} not found`);
|
|
55
|
-
}
|
|
56
|
-
await trashRef.delete();
|
|
80
|
+
const trashRef = getDb(databaseId).collection(TRASH_COLLECTION).doc(trashItemId);
|
|
81
|
+
if (!(await trashRef.get()).exists) throw new NotFoundError(`Trash item ${trashItemId} not found`);
|
|
82
|
+
await trashRef.delete();
|
|
57
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* List items in trash, optionally filtered by object type.
|
|
86
|
+
*
|
|
87
|
+
* @param objectType - Optional filter by object type
|
|
88
|
+
* @param limit - Maximum items to return (default: 100)
|
|
89
|
+
* @param databaseId - Optional named database
|
|
90
|
+
* @returns Array of trash items, newest first
|
|
91
|
+
*/
|
|
58
92
|
async function listTrash(objectType, limit = 100, databaseId) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return snapshot.docs.map((doc) => ({
|
|
66
|
-
id: doc.id,
|
|
67
|
-
...doc.data()
|
|
68
|
-
}));
|
|
93
|
+
let query = getDb(databaseId).collection(TRASH_COLLECTION).orderBy("deletedAt", "desc").limit(limit);
|
|
94
|
+
if (objectType) query = query.where("objectType", "==", objectType);
|
|
95
|
+
return (await query.get()).docs.map((doc) => ({
|
|
96
|
+
id: doc.id,
|
|
97
|
+
...doc.data()
|
|
98
|
+
}));
|
|
69
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Get a single trash item by ID.
|
|
102
|
+
*
|
|
103
|
+
* @param trashItemId - The trash document ID
|
|
104
|
+
* @param databaseId - Optional named database
|
|
105
|
+
* @returns The trash item or null if not found
|
|
106
|
+
*/
|
|
70
107
|
async function getTrashItem(trashItemId, databaseId) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return {
|
|
78
|
-
id: doc.id,
|
|
79
|
-
...doc.data()
|
|
80
|
-
};
|
|
108
|
+
const doc = await getDb(databaseId).collection(TRASH_COLLECTION).doc(trashItemId).get();
|
|
109
|
+
if (!doc.exists) return null;
|
|
110
|
+
return {
|
|
111
|
+
id: doc.id,
|
|
112
|
+
...doc.data()
|
|
113
|
+
};
|
|
81
114
|
}
|
|
82
|
-
|
|
115
|
+
//#endregion
|
|
83
116
|
export { getTrashItem, listTrash, moveToTrash, permanentDelete, restoreFromTrash };
|
package/dist/types.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
//#region src/types.ts
|
|
2
|
+
var StatusCode = {
|
|
3
|
+
OK: 200,
|
|
4
|
+
CREATED: 201,
|
|
5
|
+
BAD_REQUEST: 400,
|
|
6
|
+
UNAUTHORIZED: 401,
|
|
7
|
+
FORBIDDEN: 403,
|
|
8
|
+
NOT_FOUND: 404,
|
|
9
|
+
INTERNAL_ERROR: 500
|
|
9
10
|
};
|
|
10
|
-
|
|
11
|
+
//#endregion
|
|
11
12
|
export { StatusCode };
|
package/dist/visits.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { CallableRequest } from 'firebase-functions/v2/https';
|
|
2
|
+
/**
|
|
3
|
+
* Reach measurement — one row per visitor and day.
|
|
4
|
+
*
|
|
5
|
+
* WHY ROWS AND NOT COUNTERS: a counter answers only the question you asked
|
|
6
|
+
* when you built it. Rows can still be grouped, filtered and exported later,
|
|
7
|
+
* and every value the client sends stays a field VALUE — never a field name,
|
|
8
|
+
* where a dot would silently create a nested field.
|
|
9
|
+
*
|
|
10
|
+
* HOW DEDUPLICATION WORKS: the document id is `<day>_<visitorKey>` and the row
|
|
11
|
+
* is written with `create()`. A second call on the same day hits ALREADY_EXISTS
|
|
12
|
+
* and is dropped. No read, no counter, no transaction — the storage IS the
|
|
13
|
+
* deduplication.
|
|
14
|
+
*
|
|
15
|
+
* THE DAY IS IN THE ID, NOT IN THE KEY. That is what makes returning visitors
|
|
16
|
+
* visible: same suffix on two days is the same visitor. How long that link
|
|
17
|
+
* survives is one option (`rotate`): "day" makes rows unlinkable across days,
|
|
18
|
+
* "month" lets you recognise a returning visitor within a month. Default is
|
|
19
|
+
* the private one; an app that wants recognition asks for it.
|
|
20
|
+
*
|
|
21
|
+
* WHAT THE KEY IS WORTH: it is derived from IP and user agent. A browser
|
|
22
|
+
* update changes the user agent, mobile networks rotate IPs, a household
|
|
23
|
+
* shares one — so a recognised visitor is real, but an unrecognised one may
|
|
24
|
+
* still be the same person. It is a lower bound, not a head count. With
|
|
25
|
+
* `rotate: "month"` the key is a pseudonymous identifier: two rows carrying it
|
|
26
|
+
* are the same person, which is the point and the price.
|
|
27
|
+
*
|
|
28
|
+
* `secret` is optional. Without it the key is a plain hash of values an
|
|
29
|
+
* attacker could guess, so anyone WHO ALREADY HAS THE ROWS could test whether
|
|
30
|
+
* a given IP and browser visited. Rows are readable through the Admin SDK
|
|
31
|
+
* only; adding a constant closes even that, without changing this signature.
|
|
32
|
+
*/
|
|
33
|
+
export type VisitDevice = "phone" | "tablet" | "desktop";
|
|
34
|
+
/**
|
|
35
|
+
* What the client sends — coarse on purpose; every value is a field, not a key.
|
|
36
|
+
*
|
|
37
|
+
* Passed in SEPARATELY from the request, because an app usually wraps its calls
|
|
38
|
+
* in an envelope of its own (`{ object, type, details }`) and the payload then
|
|
39
|
+
* sits somewhere inside `request.data`. The helper reads headers from the
|
|
40
|
+
* request and takes the payload from the caller, so both shapes fit.
|
|
41
|
+
*/
|
|
42
|
+
export interface VisitInput {
|
|
43
|
+
/** Coarse device class, e.g. from a pointer/width media query. */
|
|
44
|
+
device?: VisitDevice;
|
|
45
|
+
/** Opened as an installed app (display-mode: standalone). */
|
|
46
|
+
standalone?: boolean;
|
|
47
|
+
/** Host of the referrer, reduced by the client. Never a full URL. */
|
|
48
|
+
referrer?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface CountVisitOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Count only calls from these origins — keeps dev servers out of the numbers.
|
|
53
|
+
* A site is usually reachable under more than one (hosting plus own domain).
|
|
54
|
+
*/
|
|
55
|
+
origin?: string | string[];
|
|
56
|
+
/** IANA zone the day boundary follows. Functions run in UTC; default "UTC". */
|
|
57
|
+
timeZone?: string;
|
|
58
|
+
/** How long a visitor key stays the same. Default "day". */
|
|
59
|
+
rotate?: "day" | "month";
|
|
60
|
+
/** Optional salt; see the note above. */
|
|
61
|
+
secret?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Days after which a row may be removed — written as `expires`.
|
|
64
|
+
*
|
|
65
|
+
* The FIELD is all this does. Deleting is a Firestore TTL policy on that
|
|
66
|
+
* field, switched on once per collection outside the code; without it the
|
|
67
|
+
* timestamp is documentation and nothing else. Say a retention period in a
|
|
68
|
+
* privacy notice only once the policy is on.
|
|
69
|
+
*/
|
|
70
|
+
retentionDays?: number;
|
|
71
|
+
/** Default "visits". */
|
|
72
|
+
collection?: string;
|
|
73
|
+
/** Named Firestore database. */
|
|
74
|
+
databaseId?: string;
|
|
75
|
+
}
|
|
76
|
+
export interface VisitResult {
|
|
77
|
+
counted: boolean;
|
|
78
|
+
day: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The day as YYYY-MM-DD in the given zone.
|
|
82
|
+
*
|
|
83
|
+
* "sv-SE" is the shortest way to an ISO-shaped date: `toISOString()` would be
|
|
84
|
+
* UTC, and building it by hand from parts is where off-by-one bugs live.
|
|
85
|
+
*/
|
|
86
|
+
export declare function visitDay(now: Date, timeZone: string): string;
|
|
87
|
+
/** The stretch of time a visitor key stays the same. */
|
|
88
|
+
export declare function visitPeriod(day: string, rotate: "day" | "month"): string;
|
|
89
|
+
/** The technical key — short enough to read, long enough not to collide. */
|
|
90
|
+
export declare function visitorKey(period: string, ip: string, userAgent: string, secret?: string): string;
|
|
91
|
+
/** Unknown values are dropped, never guessed and never stored raw. */
|
|
92
|
+
export declare function cleanDevice(value: unknown): VisitDevice | undefined;
|
|
93
|
+
/** A host, nothing else — no path, no query, no room for someone else's data. */
|
|
94
|
+
export declare function cleanReferrer(value: unknown): string | undefined;
|
|
95
|
+
export declare function isBot(userAgent: string): boolean;
|
|
96
|
+
export interface BrowserInfo {
|
|
97
|
+
/** Family as the user would name it, or "other". */
|
|
98
|
+
browser: string;
|
|
99
|
+
/** MAJOR version only. The minor and patch parts identify, they do not inform. */
|
|
100
|
+
version?: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Browser family and major version.
|
|
104
|
+
*
|
|
105
|
+
* ORDER IS THE WHOLE TRICK, because these strings quote each other: Edge says
|
|
106
|
+
* Edg AND Chrome AND Safari, Chrome says Chrome AND Safari. So the most
|
|
107
|
+
* specific claim wins, and Safari is asked last. On iOS every engine is
|
|
108
|
+
* Safari's, but the browsers name themselves (CriOS, FxiOS) — reported as what
|
|
109
|
+
* the user sees, since that is what the question is about.
|
|
110
|
+
*
|
|
111
|
+
* Unclassified traffic becomes "other" rather than nothing: an unreadable
|
|
112
|
+
* agent is a fact worth seeing, missing data is a different fact.
|
|
113
|
+
*/
|
|
114
|
+
export declare function browserOf(userAgent: string): BrowserInfo;
|
|
115
|
+
/**
|
|
116
|
+
* Operating system family, never a version.
|
|
117
|
+
*
|
|
118
|
+
* Android carries "Linux" in its agent and an iPad in desktop mode calls
|
|
119
|
+
* itself a Macintosh — so the order matters here too, and the iPad stays
|
|
120
|
+
* wrong. That is a limit of the source, not something to guess around.
|
|
121
|
+
*/
|
|
122
|
+
export declare function osOf(userAgent: string): string;
|
|
123
|
+
/** No list means no restriction; an empty match means this call does not count. */
|
|
124
|
+
export declare function originAllowed(origin: unknown, allowed?: string | string[]): boolean;
|
|
125
|
+
/** First entry of x-forwarded-for; the rest are proxies. */
|
|
126
|
+
export declare function clientIp(headers: Record<string, unknown>): string | undefined;
|
|
127
|
+
/**
|
|
128
|
+
* Count one visit — or recognise that this visitor was already counted today.
|
|
129
|
+
*
|
|
130
|
+
* Never throws for the ordinary "does not count" cases (wrong origin, bot, no
|
|
131
|
+
* address, already seen): the caller is a fire-and-forget call from a page
|
|
132
|
+
* that must not care.
|
|
133
|
+
*/
|
|
134
|
+
export declare function countVisit(request: CallableRequest, visit?: VisitInput, options?: CountVisitOptions): Promise<VisitResult>;
|
|
135
|
+
//# sourceMappingURL=visits.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"visits.d.ts","sourceRoot":"","sources":["../src/visits.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAI9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEzD;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,kEAAkE;IAClE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,KAAK,GAAG,OAAO,CAAC;IACzB,yCAAyC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wBAAwB;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gCAAgC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;CACb;AAWD;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED,wDAAwD;AACxD,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,GAAG,OAAO,GAAG,MAAM,CAExE;AAED,4EAA4E;AAC5E,wBAAgB,UAAU,CACxB,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,MAAM,SAAK,GACV,MAAM,CAKR;AAED,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,GAAG,SAAS,CAInE;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAIhE;AAED,wBAAgB,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED,MAAM,WAAW,WAAW;IAC1B,oDAAoD;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,WAAW,CAgBxD;AAED;;;;;;GAMG;AACH,wBAAgB,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAQ9C;AAED,mFAAmF;AACnF,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,GAC1B,OAAO,CAIT;AAED,4DAA4D;AAC5D,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,SAAS,CAM7E;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,eAAe,EACxB,KAAK,GAAE,UAAe,EACtB,OAAO,GAAE,iBAAsB,GAC9B,OAAO,CAAC,WAAW,CAAC,CAqDtB"}
|
package/dist/visits.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
*/
|
|
20
|
+
function visitDay(now, timeZone) {
|
|
21
|
+
return new Intl.DateTimeFormat("sv-SE", { timeZone }).format(now);
|
|
22
|
+
}
|
|
23
|
+
/** The stretch of time a visitor key stays the same. */
|
|
24
|
+
function visitPeriod(day, rotate) {
|
|
25
|
+
return rotate === "month" ? day.slice(0, 7) : day;
|
|
26
|
+
}
|
|
27
|
+
/** The technical key — short enough to read, long enough not to collide. */
|
|
28
|
+
function visitorKey(period, ip, userAgent, secret = "") {
|
|
29
|
+
return createHash("sha256").update(`${secret}|${period}|${ip}|${userAgent}`).digest("hex").slice(0, 16);
|
|
30
|
+
}
|
|
31
|
+
/** Unknown values are dropped, never guessed and never stored raw. */
|
|
32
|
+
function cleanDevice(value) {
|
|
33
|
+
return typeof value === "string" && DEVICES.includes(value) ? value : void 0;
|
|
34
|
+
}
|
|
35
|
+
/** A host, nothing else — no path, no query, no room for someone else's data. */
|
|
36
|
+
function cleanReferrer(value) {
|
|
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;
|
|
40
|
+
}
|
|
41
|
+
function isBot(userAgent) {
|
|
42
|
+
return BOTS.test(userAgent);
|
|
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
|
+
*/
|
|
56
|
+
function browserOf(userAgent) {
|
|
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" };
|
|
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
|
+
*/
|
|
82
|
+
function osOf(userAgent) {
|
|
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";
|
|
90
|
+
}
|
|
91
|
+
/** No list means no restriction; an empty match means this call does not count. */
|
|
92
|
+
function originAllowed(origin, allowed) {
|
|
93
|
+
if (!allowed) return true;
|
|
94
|
+
return typeof origin === "string" && (Array.isArray(allowed) ? allowed : [allowed]).includes(origin);
|
|
95
|
+
}
|
|
96
|
+
/** First entry of x-forwarded-for; the rest are proxies. */
|
|
97
|
+
function clientIp(headers) {
|
|
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;
|
|
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
|
+
*/
|
|
110
|
+
async function countVisit(request, visit = {}, options = {}) {
|
|
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
|
+
}
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
export { browserOf, cleanDevice, cleanReferrer, clientIp, countVisit, isBot, originAllowed, osOf, visitDay, visitPeriod, visitorKey };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"visits.spec.d.ts","sourceRoot":"","sources":["../src/visits.spec.ts"],"names":[],"mappings":""}
|
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.3.1",
|
|
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",
|
|
@@ -17,13 +17,12 @@
|
|
|
17
17
|
"README.md",
|
|
18
18
|
"LICENSE"
|
|
19
19
|
],
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"firebase-functions": "^6.0.0"
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": "22"
|
|
23
22
|
},
|
|
24
23
|
"peerDependencies": {
|
|
25
|
-
"firebase-admin": "
|
|
26
|
-
"firebase-functions": "
|
|
24
|
+
"firebase-admin": ">=12.6.0 <15",
|
|
25
|
+
"firebase-functions": ">=6 <8"
|
|
27
26
|
},
|
|
28
27
|
"keywords": [
|
|
29
28
|
"pd",
|
|
@@ -33,12 +32,16 @@
|
|
|
33
32
|
"functions",
|
|
34
33
|
"backend"
|
|
35
34
|
],
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"firebase-admin": "^14.3.0",
|
|
37
|
+
"firebase-functions": "^7.3.2"
|
|
38
|
+
},
|
|
36
39
|
"scripts": {
|
|
37
40
|
"build": "vite build",
|
|
38
41
|
"clean": "rm -rf dist",
|
|
39
42
|
"clean:all": "rm -rf dist node_modules pnpm-lock.yaml",
|
|
40
|
-
"lint": "eslint
|
|
41
|
-
"format": "eslint
|
|
43
|
+
"lint": "eslint src && prettier \"**/*.ts\" --check --ignore-path ../../.eslintignore",
|
|
44
|
+
"format": "eslint src --fix && prettier \"**/*.ts\" --write --ignore-path ../../.eslintignore",
|
|
42
45
|
"check": "pnpm run lint && pnpm run build",
|
|
43
46
|
"test": "vitest run"
|
|
44
47
|
}
|