@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 CHANGED
@@ -1 +1 @@
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};
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@koendhoore/directus-extension-umami-analytics",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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,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 base64url(input: Buffer | string) {
15
- return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
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 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\/?$/, '');
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 hash(...args: string[]) {
123
- return crypto.createHash('sha512').update(args.join('')).digest('hex');
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
- function numberParam(value: unknown, fallback: number) {
137
- const parsed = Number(value);
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
- 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
- }
139
+ const apiClientAuth = getApiClientAuth(env);
140
+ if (apiClientAuth) return apiClientAuth;
146
141
 
147
- function setCached(key: string, value: unknown, ttlMs = FIVE_MINUTES) {
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
- 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;
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
- 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
- });
212
- }
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({
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
- 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
- };
234
+ const stats = await umamiGet(env, `/websites/${websiteId}/stats?${statsParams.toString()}`);
276
235
 
277
- setCached(key, payload);
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
- res.json(payload);
280
- } catch (error: any) {
281
- res.status(500).json({
282
- error: error.message || 'Unknown error',
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
  };