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