@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 CHANGED
@@ -1 +1 @@
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 d(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,d=o.headers.get("set-cookie")||"",f=u?{headers:{Authorization:`Bearer ${u}`}}:{headers:d?{Cookie:d.split(";")[0]}:{}};return r={expires:Date.now()+33e5,auth:f},f}(t)}async function f(t,e){const r=await d(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()}`}const p=[],M=[{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 f(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}`,d=function(t){const r=e.get(t);return!r||r.expires<Date.now()?null:r.value}(u);if(d)return r.json(d);const p=new URLSearchParams({startAt:String(Math.round(s)),endAt:String(Math.round(i))}),[M,l,A,m]=await Promise.all([f(n,`/websites/${a}/stats?${p.toString()}`),f(n,h(a,"url",s,i,10)),f(n,h(a,"referrer",s,i,10)),f(n,h(a,"event",s,i,10)).catch(()=>[])]),g={websiteId:a,range:{startAt:s,endAt:i,days:c},stats:M,pages:l,referrers:A,events:m};!function(t,r,n=3e5){e.set(t,{expires:Date.now()+n,value:r})}(u,g),r.json(g)}catch(t){r.status(500).json({error:t.message||"Unknown error"})}})}}],l=[];export{M as endpoints,p as hooks,l as operations};
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@koendhoore/directus-extension-umami-analytics",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Directus bundle: secure Umami proxy endpoint + native Insights analytics panel.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,4 +1,4 @@
1
- import crypto from 'node:crypto';
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 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 };
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 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');
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
- async function getAuth(env: Env): Promise<UmamiAuth> {
133
- const apiKey = env.UMAMI_API_KEY;
134
- if (apiKey) return { headers: { 'x-umami-api-key': apiKey } };
122
+ function hash(...args: string[]) {
123
+ return crypto.createHash('sha512').update(args.join('')).digest('hex');
124
+ }
135
125
 
136
- const token = env.UMAMI_TOKEN;
137
- if (token) return { headers: { Authorization: `Bearer ${token}` } };
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
- const apiClientAuth = getApiClientAuth(env);
140
- if (apiClientAuth) return apiClientAuth;
136
+ function numberParam(value: unknown, fallback: number) {
137
+ const parsed = Number(value);
138
+ return Number.isFinite(parsed) ? parsed : fallback;
139
+ }
141
140
 
142
- return getUsernamePasswordAuth(env);
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
- 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
- }
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
- try {
206
- const websiteId = String(req.query.websiteId || env.UMAMI_WEBSITE_ID || '');
207
- if (!websiteId) return res.status(400).json({ error: 'Missing websiteId or UMAMI_WEBSITE_ID' });
208
-
209
- const { startAt, endAt, days } = getRange(req.query);
210
- const key = `summary:${websiteId}:${startAt}:${endAt}`;
211
- const hit = cached(key);
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
  };