@koendhoore/directus-extension-umami-analytics 1.0.2 → 1.0.3
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/api.js +1 -1
- package/package.json +1 -1
- package/src/umami-analytics-endpoint/index.ts +96 -133
package/dist/api.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import t from"node:crypto";function e(t,e){const r=e.replace(/^\/api\/?/,"").replace(/^\/+/,"");return`${
|
|
1
|
+
import t from"node:crypto";const e=new Map;let r=null;function n(t,e){const r=t[e];if(!r)throw new Error(`Missing ${e}`);return r.replace(/\/+$/,"")}function a(t){return n(t,"UMAMI_URL").replace(/\/api\/?$/,"")}function s(t,e){const r=e.replace(/^\/api\/?/,"").replace(/^\/+/,"");return`${a(t)}/api/${r}`}function o(t,e){const r=Number(t);return Number.isFinite(r)?r:e}function i(t){return Buffer.from(t).toString("base64").replace(/=/g,"").replace(/\+/g,"-").replace(/\//g,"_")}function c(e,r){return function(e,r){const n=t.randomBytes(16),a=t.randomBytes(64),s=t.pbkdf2Sync(r,a,1e4,32,"sha512"),o=t.createCipheriv("aes-256-gcm",s,n),i=Buffer.concat([o.update(String(e),"utf8"),o.final()]),c=o.getAuthTag();return Buffer.concat([a,n,c,i]).toString("base64")}(function(e,r){const n=`${i(JSON.stringify({alg:"HS256",typ:"JWT"}))}.${i(JSON.stringify(e))}`;return`${n}.${i(t.createHmac("sha256",r).update(n).digest())}`}(e,r),r)}function u(e){const r=e.UMAMI_USER_ID||e.UMAMI_API_CLIENT_USER_ID,n=e.UMAMI_APP_SECRET||e.UMAMI_API_CLIENT_SECRET;if(!r||!n)return null;return{headers:{Authorization:`Bearer ${c({userId:r},function(...e){return t.createHash("sha512").update(e.join("")).digest("hex")}(n))}`}}}async function f(t){const e=t.UMAMI_API_KEY;if(e)return{headers:{"x-umami-api-key":e}};const a=t.UMAMI_TOKEN;if(a)return{headers:{Authorization:`Bearer ${a}`}};const o=u(t);return o||async function(t){if(r&&r.expires>Date.now())return r.auth;const e=n(t,"UMAMI_USERNAME"),a=n(t,"UMAMI_PASSWORD"),o=await fetch(s(t,"/auth/login"),{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({username:e,password:a})}),i=await o.text();if(!o.ok)throw new Error(`Umami login failed: ${o.status} ${i}`);let c=null;try{c=i?JSON.parse(i):null}catch{}const u=c?.token||c?.accessToken||c?.data?.token||c?.data?.accessToken,f=o.headers.get("set-cookie")||"",d=u?{headers:{Authorization:`Bearer ${u}`}}:{headers:f?{Cookie:f.split(";")[0]}:{}};return r={expires:Date.now()+33e5,auth:d},d}(t)}async function d(t,e){const r=await f(t),n=await fetch(s(t,e),{headers:{Accept:"application/json",...r.headers}}),a=await n.text();if(!n.ok)throw new Error(`Umami request failed: ${n.status} ${a}`);return a?JSON.parse(a):null}function h(t,e,r,n,a=10){return`/websites/${t}/metrics?${new URLSearchParams({startAt:String(Math.round(r)),endAt:String(Math.round(n)),type:e,limit:String(a)}).toString()}`}async function p(t,e,r=[]){try{return await d(t,e)}catch(t){return{error:t.message||"Unknown Umami error",path:e,fallback:r}}}const m=[],l=[{name:"umami-analytics",config:(t,r)=>{const n=r.env||process.env;t.get("/health",(t,e)=>{e.json({ok:!0,service:"umami-analytics"})}),t.get("/debug",async(t,e)=>{try{const t=n.UMAMI_API_KEY?"api-key":n.UMAMI_TOKEN?"bearer-token":n.UMAMI_USER_ID||n.UMAMI_API_CLIENT_USER_ID?"api-client":"username-password",r=await d(n,"/me").catch(t=>({error:t.message}));e.json({ok:!0,authMode:t,umamiUrl:a(n),websiteId:n.UMAMI_WEBSITE_ID,me:r})}catch(t){e.status(500).json({error:t.message||"Unknown error"})}}),t.get("/summary",async(t,r)=>{try{const a=String(t.query.websiteId||n.UMAMI_WEBSITE_ID||"");if(!a)return r.status(400).json({error:"Missing websiteId or UMAMI_WEBSITE_ID"});const{startAt:s,endAt:i,days:c}=function(t){const e=Date.now(),r=Math.max(1,Math.min(365,o(t.days,30))),n=o(t.endAt,e);return{startAt:o(t.startAt,n-24*r*60*60*1e3),endAt:n,days:r}}(t.query),u=`summary:${a}:${s}:${i}:v3`,f=function(t){const r=e.get(t);return!r||r.expires<Date.now()?null:r.value}(u);if(f)return r.json(f);const m=new URLSearchParams({startAt:String(Math.round(s)),endAt:String(Math.round(i))}),l=await d(n,`/websites/${a}/stats?${m.toString()}`),[M,A,g]=await Promise.all([p(n,h(a,"path",s,i,10),[]),p(n,h(a,"referrer",s,i,10),[]),p(n,h(a,"event",s,i,10),[])]),I={websiteId:a,range:{startAt:s,endAt:i,days:c},stats:l,pages:M,referrers:A,events:g};!function(t,r,n=3e5){e.set(t,{expires:Date.now()+n,value:r})}(u,I),r.json(I)}catch(t){r.status(500).json({error:t.message||"Unknown error"})}})}}],M=[];export{l as endpoints,m as hooks,M as operations};
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import crypto from
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
type Env = Record<string, string | undefined>;
|
|
4
4
|
|
|
@@ -6,13 +6,37 @@ type UmamiAuth = {
|
|
|
6
6
|
headers: Record<string, string>;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
+
const FIVE_MINUTES = 5 * 60 * 1000;
|
|
10
|
+
const cache = new Map<string, { expires: number; value: unknown }>();
|
|
11
|
+
let authCache: { expires: number; auth: UmamiAuth } | null = null;
|
|
12
|
+
|
|
13
|
+
function required(env: Env, key: string): string {
|
|
14
|
+
const value = env[key];
|
|
15
|
+
if (!value) throw new Error(`Missing ${key}`);
|
|
16
|
+
return value.replace(/\/+$/, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cleanBaseUrl(env: Env): string {
|
|
20
|
+
// Accept both https://analytics.example.com and https://analytics.example.com/api
|
|
21
|
+
return required(env, 'UMAMI_URL').replace(/\/api\/?$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
function apiUrl(env: Env, path: string): string {
|
|
10
25
|
const cleanPath = path.replace(/^\/api\/?/, '').replace(/^\/+/, '');
|
|
11
26
|
return `${cleanBaseUrl(env)}/api/${cleanPath}`;
|
|
12
27
|
}
|
|
13
28
|
|
|
14
|
-
function
|
|
15
|
-
|
|
29
|
+
function numberParam(value: unknown, fallback: number) {
|
|
30
|
+
const parsed = Number(value);
|
|
31
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getRange(query: Record<string, unknown>) {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const days = Math.max(1, Math.min(365, numberParam(query.days, 30)));
|
|
37
|
+
const endAt = numberParam(query.endAt, now);
|
|
38
|
+
const startAt = numberParam(query.startAt, endAt - days * 24 * 60 * 60 * 1000);
|
|
39
|
+
return { startAt, endAt, days };
|
|
16
40
|
}
|
|
17
41
|
|
|
18
42
|
function cached<T>(key: string): T | null {
|
|
@@ -21,9 +45,16 @@ function cached<T>(key: string): T | null {
|
|
|
21
45
|
return hit.value as T;
|
|
22
46
|
}
|
|
23
47
|
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
48
|
+
function setCached(key: string, value: unknown, ttlMs = FIVE_MINUTES) {
|
|
49
|
+
cache.set(key, { expires: Date.now() + ttlMs, value });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function base64url(input: Buffer | string) {
|
|
53
|
+
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hash(...args: string[]) {
|
|
57
|
+
return crypto.createHash('sha512').update(args.join('')).digest('hex');
|
|
27
58
|
}
|
|
28
59
|
|
|
29
60
|
function createJwt(payload: Record<string, unknown>, secret: string) {
|
|
@@ -35,10 +66,6 @@ function createJwt(payload: Record<string, unknown>, secret: string) {
|
|
|
35
66
|
return `${data}.${base64url(signature)}`;
|
|
36
67
|
}
|
|
37
68
|
|
|
38
|
-
function createSecureToken(payload: Record<string, unknown>, secret: string) {
|
|
39
|
-
return encryptAesGcm(createJwt(payload, secret), secret);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
69
|
function encryptAesGcm(value: string, secret: string) {
|
|
43
70
|
const iv = crypto.randomBytes(16);
|
|
44
71
|
const salt = crypto.randomBytes(64);
|
|
@@ -49,6 +76,10 @@ function encryptAesGcm(value: string, secret: string) {
|
|
|
49
76
|
return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
|
|
50
77
|
}
|
|
51
78
|
|
|
79
|
+
function createSecureToken(payload: Record<string, unknown>, secret: string) {
|
|
80
|
+
return encryptAesGcm(createJwt(payload, secret), secret);
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
function getApiClientAuth(env: Env): UmamiAuth | null {
|
|
53
84
|
const userId = env.UMAMI_USER_ID || env.UMAMI_API_CLIENT_USER_ID;
|
|
54
85
|
const appSecret = env.UMAMI_APP_SECRET || env.UMAMI_API_CLIENT_SECRET;
|
|
@@ -62,27 +93,6 @@ function getApiClientAuth(env: Env): UmamiAuth | null {
|
|
|
62
93
|
return { headers: { Authorization: `Bearer ${token}` } };
|
|
63
94
|
}
|
|
64
95
|
|
|
65
|
-
async function getAuth(env: Env): Promise<UmamiAuth> {
|
|
66
|
-
const apiKey = env.UMAMI_API_KEY;
|
|
67
|
-
if (apiKey) return { headers: { 'x-umami-api-key': apiKey } };
|
|
68
|
-
|
|
69
|
-
const token = env.UMAMI_TOKEN;
|
|
70
|
-
if (token) return { headers: { Authorization: `Bearer ${token}` } };
|
|
71
|
-
|
|
72
|
-
const apiClientAuth = getApiClientAuth(env);
|
|
73
|
-
if (apiClientAuth) return apiClientAuth;
|
|
74
|
-
|
|
75
|
-
return getUsernamePasswordAuth(env);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function getRange(query: Record<string, unknown>) {
|
|
79
|
-
const now = Date.now();
|
|
80
|
-
const days = Math.max(1, Math.min(365, numberParam(query.days, 30)));
|
|
81
|
-
const endAt = numberParam(query.endAt, now);
|
|
82
|
-
const startAt = numberParam(query.startAt, endAt - days * 24 * 60 * 60 * 1000);
|
|
83
|
-
return { startAt, endAt, days };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
96
|
async function getUsernamePasswordAuth(env: Env): Promise<UmamiAuth> {
|
|
87
97
|
if (authCache && authCache.expires > Date.now()) return authCache.auth;
|
|
88
98
|
|
|
@@ -119,33 +129,17 @@ async function getUsernamePasswordAuth(env: Env): Promise<UmamiAuth> {
|
|
|
119
129
|
return auth;
|
|
120
130
|
}
|
|
121
131
|
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function metricPath(websiteId: string, type: string, startAt: number, endAt: number, limit = 10) {
|
|
127
|
-
const params = new URLSearchParams({
|
|
128
|
-
startAt: String(Math.round(startAt)),
|
|
129
|
-
endAt: String(Math.round(endAt)),
|
|
130
|
-
type,
|
|
131
|
-
limit: String(limit),
|
|
132
|
-
});
|
|
133
|
-
return `/websites/${websiteId}/metrics?${params.toString()}`;
|
|
134
|
-
}
|
|
132
|
+
async function getAuth(env: Env): Promise<UmamiAuth> {
|
|
133
|
+
const apiKey = env.UMAMI_API_KEY;
|
|
134
|
+
if (apiKey) return { headers: { 'x-umami-api-key': apiKey } };
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return Number.isFinite(parsed) ? parsed : fallback;
|
|
139
|
-
}
|
|
136
|
+
const token = env.UMAMI_TOKEN;
|
|
137
|
+
if (token) return { headers: { Authorization: `Bearer ${token}` } };
|
|
140
138
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (!value) throw new Error(`Missing ${key}`);
|
|
144
|
-
return value.replace(/\/+$/, '');
|
|
145
|
-
}
|
|
139
|
+
const apiClientAuth = getApiClientAuth(env);
|
|
140
|
+
if (apiClientAuth) return apiClientAuth;
|
|
146
141
|
|
|
147
|
-
|
|
148
|
-
cache.set(key, { expires: Date.now() + ttlMs, value });
|
|
142
|
+
return getUsernamePasswordAuth(env);
|
|
149
143
|
}
|
|
150
144
|
|
|
151
145
|
async function umamiGet(env: Env, path: string) {
|
|
@@ -166,9 +160,27 @@ async function umamiGet(env: Env, path: string) {
|
|
|
166
160
|
return text ? JSON.parse(text) : null;
|
|
167
161
|
}
|
|
168
162
|
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
163
|
+
function metricPath(websiteId: string, type: string, startAt: number, endAt: number, limit = 10) {
|
|
164
|
+
const params = new URLSearchParams({
|
|
165
|
+
startAt: String(Math.round(startAt)),
|
|
166
|
+
endAt: String(Math.round(endAt)),
|
|
167
|
+
type,
|
|
168
|
+
limit: String(limit),
|
|
169
|
+
});
|
|
170
|
+
return `/websites/${websiteId}/metrics?${params.toString()}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function safeUmamiGet(env: Env, path: string, fallback: unknown = []) {
|
|
174
|
+
try {
|
|
175
|
+
return await umamiGet(env, path);
|
|
176
|
+
} catch (error: any) {
|
|
177
|
+
return {
|
|
178
|
+
error: error.message || 'Unknown Umami error',
|
|
179
|
+
path,
|
|
180
|
+
fallback,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
172
184
|
|
|
173
185
|
export default (router: any, context: any) => {
|
|
174
186
|
const env: Env = context.env || process.env;
|
|
@@ -202,85 +214,36 @@ export default (router: any, context: any) => {
|
|
|
202
214
|
});
|
|
203
215
|
|
|
204
216
|
router.get('/summary', async (req: any, res: any) => {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const hit = cached(key);
|
|
219
|
-
|
|
220
|
-
if (hit) {
|
|
221
|
-
return res.json(hit);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const timezone = env.UMAMI_TIMEZONE || 'Europe/Brussels';
|
|
225
|
-
|
|
226
|
-
const statsParams = new URLSearchParams({
|
|
227
|
-
startAt: String(Math.round(startAt)),
|
|
228
|
-
endAt: String(Math.round(endAt)),
|
|
229
|
-
unit: 'day',
|
|
230
|
-
timezone,
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
const metricsParams = (type: string, limit = 10) =>
|
|
234
|
-
new URLSearchParams({
|
|
217
|
+
try {
|
|
218
|
+
const websiteId = String(req.query.websiteId || env.UMAMI_WEBSITE_ID || '');
|
|
219
|
+
if (!websiteId) return res.status(400).json({ error: 'Missing websiteId or UMAMI_WEBSITE_ID' });
|
|
220
|
+
|
|
221
|
+
const { startAt, endAt, days } = getRange(req.query);
|
|
222
|
+
const key = `summary:${websiteId}:${startAt}:${endAt}:v3`;
|
|
223
|
+
const hit = cached(key);
|
|
224
|
+
if (hit) return res.json(hit);
|
|
225
|
+
|
|
226
|
+
// Umami v3:
|
|
227
|
+
// - /stats accepts startAt/endAt only.
|
|
228
|
+
// - /metrics uses type=path, not type=url.
|
|
229
|
+
const statsParams = new URLSearchParams({
|
|
235
230
|
startAt: String(Math.round(startAt)),
|
|
236
231
|
endAt: String(Math.round(endAt)),
|
|
237
|
-
type,
|
|
238
|
-
limit: String(limit),
|
|
239
|
-
timezone,
|
|
240
232
|
});
|
|
241
233
|
|
|
242
|
-
|
|
243
|
-
umamiGet(
|
|
244
|
-
env,
|
|
245
|
-
`/websites/${websiteId}/stats?${statsParams.toString()}`
|
|
246
|
-
),
|
|
247
|
-
|
|
248
|
-
umamiGet(
|
|
249
|
-
env,
|
|
250
|
-
`/websites/${websiteId}/metrics?${metricsParams('url').toString()}`
|
|
251
|
-
),
|
|
252
|
-
|
|
253
|
-
umamiGet(
|
|
254
|
-
env,
|
|
255
|
-
`/websites/${websiteId}/metrics?${metricsParams('referrer').toString()}`
|
|
256
|
-
),
|
|
257
|
-
|
|
258
|
-
umamiGet(
|
|
259
|
-
env,
|
|
260
|
-
`/websites/${websiteId}/metrics?${metricsParams('event').toString()}`
|
|
261
|
-
).catch(() => []),
|
|
262
|
-
]);
|
|
263
|
-
|
|
264
|
-
const payload = {
|
|
265
|
-
websiteId,
|
|
266
|
-
range: {
|
|
267
|
-
startAt,
|
|
268
|
-
endAt,
|
|
269
|
-
days,
|
|
270
|
-
},
|
|
271
|
-
stats,
|
|
272
|
-
pages,
|
|
273
|
-
referrers,
|
|
274
|
-
events,
|
|
275
|
-
};
|
|
234
|
+
const stats = await umamiGet(env, `/websites/${websiteId}/stats?${statsParams.toString()}`);
|
|
276
235
|
|
|
277
|
-
|
|
236
|
+
const [pages, referrers, events] = await Promise.all([
|
|
237
|
+
safeUmamiGet(env, metricPath(websiteId, 'path', startAt, endAt, 10), []),
|
|
238
|
+
safeUmamiGet(env, metricPath(websiteId, 'referrer', startAt, endAt, 10), []),
|
|
239
|
+
safeUmamiGet(env, metricPath(websiteId, 'event', startAt, endAt, 10), []),
|
|
240
|
+
]);
|
|
278
241
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
});
|
|
242
|
+
const payload = { websiteId, range: { startAt, endAt, days }, stats, pages, referrers, events };
|
|
243
|
+
setCached(key, payload);
|
|
244
|
+
res.json(payload);
|
|
245
|
+
} catch (error: any) {
|
|
246
|
+
res.status(500).json({ error: error.message || 'Unknown error' });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
286
249
|
};
|