@koendhoore/directus-extension-umami-analytics 1.0.0 → 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
- const t=new Map;let e=null;function n(t,e){const n=t[e];if(!n)throw new Error(`Missing ${e}`);return n.replace(/\/$/,"")}function r(t,e){const n=Number(t);return Number.isFinite(n)?n:e}async function a(t,r){const a=n(t,"UMAMI_URL"),s=await async function(t){const r=t.UMAMI_TOKEN||t.UMAMI_API_KEY;if(r)return{headers:{Authorization:`Bearer ${r}`}};if(e&&e.expires>Date.now())return e.auth;const a=n(t,"UMAMI_URL"),s=n(t,"UMAMI_USERNAME"),o=n(t,"UMAMI_PASSWORD"),i=await fetch(`${a}/api/auth/login`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:s,password:o})});if(!i.ok){const t=await i.text();throw new Error(`Umami login failed: ${i.status} ${t}`)}const c=i.headers.get("set-cookie")||"";let u=null;try{u=await i.json()}catch{}const d=u?.token||u?.accessToken||u?.data?.token||u?.data?.accessToken,h=d?{headers:{Authorization:`Bearer ${d}`}}:{headers:c?{Cookie:c.split(";")[0]}:{}};return e={expires:Date.now()+33e5,auth:h},h}(t),o=await fetch(`${a}${r}`,{headers:{Accept:"application/json",...s.headers}});if(!o.ok){const t=await o.text();throw new Error(`Umami request failed: ${o.status} ${t}`)}return o.json()}function s(t,e,n,r,a=10){return`/api/websites/${t}/metrics?${new URLSearchParams({startAt:String(Math.round(n)),endAt:String(Math.round(r)),type:e,limit:String(a)}).toString()}`}const o=[],i=[{name:"umami-analytics",config:(e,n)=>{const o=n.env||process.env;e.get("/health",(t,e)=>{e.json({ok:!0,service:"umami-analytics"})}),e.get("/summary",async(e,n)=>{try{const i=String(e.query.websiteId||o.UMAMI_WEBSITE_ID||"");if(!i)return n.status(400).json({error:"Missing websiteId or UMAMI_WEBSITE_ID"});const{startAt:c,endAt:u,days:d}=function(t){const e=Date.now(),n=Math.max(1,Math.min(365,r(t.days,30))),a=r(t.endAt,e);return{startAt:r(t.startAt,a-24*n*60*60*1e3),endAt:a,days:n}}(e.query),h=`summary:${i}:${c}:${u}`,w=function(e){const n=t.get(e);return!n||n.expires<Date.now()?null:n.value}(h);if(w)return n.json(w);const A=new URLSearchParams({startAt:String(Math.round(c)),endAt:String(Math.round(u))}),[M,l,m,f]=await Promise.all([a(o,`/api/websites/${i}/stats?${A.toString()}`),a(o,s(i,"url",c,u,10)),a(o,s(i,"referrer",c,u,10)),a(o,s(i,"event",c,u,10)).catch(()=>[])]),g={websiteId:i,range:{startAt:c,endAt:u,days:d},stats:M,pages:l,referrers:m,events:f};!function(e,n,r=3e5){t.set(e,{expires:Date.now()+r,value:n})}(h,g),n.json(g)}catch(t){n.status(500).json({error:t.message||"Unknown error"})}})}}],c=[];export{i as endpoints,o as hooks,c 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.0",
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,31 +1,18 @@
1
+ import crypto from "node:crypto";
2
+
1
3
  type Env = Record<string, string | undefined>;
2
4
 
3
5
  type UmamiAuth = {
4
6
  headers: Record<string, string>;
5
- cookie?: string;
6
7
  };
7
8
 
8
- const FIVE_MINUTES = 5 * 60 * 1000;
9
- const cache = new Map<string, { expires: number; value: unknown }>();
10
- let authCache: { expires: number; auth: UmamiAuth } | null = null;
11
-
12
- function required(env: Env, key: string): string {
13
- const value = env[key];
14
- if (!value) throw new Error(`Missing ${key}`);
15
- return value.replace(/\/$/, '');
16
- }
17
-
18
- function numberParam(value: unknown, fallback: number) {
19
- const parsed = Number(value);
20
- return Number.isFinite(parsed) ? parsed : fallback;
9
+ function apiUrl(env: Env, path: string): string {
10
+ const cleanPath = path.replace(/^\/api\/?/, '').replace(/^\/+/, '');
11
+ return `${cleanBaseUrl(env)}/api/${cleanPath}`;
21
12
  }
22
13
 
23
- function getRange(query: Record<string, unknown>) {
24
- const now = Date.now();
25
- const days = Math.max(1, Math.min(365, numberParam(query.days, 30)));
26
- const endAt = numberParam(query.endAt, now);
27
- const startAt = numberParam(query.startAt, endAt - days * 24 * 60 * 60 * 1000);
28
- return { startAt, endAt, days };
14
+ function base64url(input: Buffer | string) {
15
+ return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
29
16
  }
30
17
 
31
18
  function cached<T>(key: string): T | null {
@@ -34,40 +21,96 @@ function cached<T>(key: string): T | null {
34
21
  return hit.value as T;
35
22
  }
36
23
 
37
- function setCached(key: string, value: unknown, ttlMs = FIVE_MINUTES) {
38
- cache.set(key, { expires: Date.now() + ttlMs, value });
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\/?$/, '');
27
+ }
28
+
29
+ function createJwt(payload: Record<string, unknown>, secret: string) {
30
+ const header = { alg: 'HS256', typ: 'JWT' };
31
+ const encodedHeader = base64url(JSON.stringify(header));
32
+ const encodedPayload = base64url(JSON.stringify(payload));
33
+ const data = `${encodedHeader}.${encodedPayload}`;
34
+ const signature = crypto.createHmac('sha256', secret).update(data).digest();
35
+ return `${data}.${base64url(signature)}`;
36
+ }
37
+
38
+ function createSecureToken(payload: Record<string, unknown>, secret: string) {
39
+ return encryptAesGcm(createJwt(payload, secret), secret);
40
+ }
41
+
42
+ function encryptAesGcm(value: string, secret: string) {
43
+ const iv = crypto.randomBytes(16);
44
+ const salt = crypto.randomBytes(64);
45
+ const key = crypto.pbkdf2Sync(secret, salt, 10000, 32, 'sha512');
46
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
47
+ const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
48
+ const tag = cipher.getAuthTag();
49
+ return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
50
+ }
51
+
52
+ function getApiClientAuth(env: Env): UmamiAuth | null {
53
+ const userId = env.UMAMI_USER_ID || env.UMAMI_API_CLIENT_USER_ID;
54
+ const appSecret = env.UMAMI_APP_SECRET || env.UMAMI_API_CLIENT_SECRET;
55
+
56
+ if (!userId || !appSecret) return null;
57
+
58
+ // This matches @umami/api-client: secret is sha512(APP_SECRET), then createSecureToken({ userId }, secret)
59
+ const secret = hash(appSecret);
60
+ const token = createSecureToken({ userId }, secret);
61
+
62
+ return { headers: { Authorization: `Bearer ${token}` } };
39
63
  }
40
64
 
41
65
  async function getAuth(env: Env): Promise<UmamiAuth> {
42
- const token = env.UMAMI_TOKEN || env.UMAMI_API_KEY;
66
+ const apiKey = env.UMAMI_API_KEY;
67
+ if (apiKey) return { headers: { 'x-umami-api-key': apiKey } };
68
+
69
+ const token = env.UMAMI_TOKEN;
43
70
  if (token) return { headers: { Authorization: `Bearer ${token}` } };
44
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
+ async function getUsernamePasswordAuth(env: Env): Promise<UmamiAuth> {
45
87
  if (authCache && authCache.expires > Date.now()) return authCache.auth;
46
88
 
47
- const baseUrl = required(env, 'UMAMI_URL');
48
89
  const username = required(env, 'UMAMI_USERNAME');
49
90
  const password = required(env, 'UMAMI_PASSWORD');
50
91
 
51
- const response = await fetch(`${baseUrl}/api/auth/login`, {
92
+ const response = await fetch(apiUrl(env, '/auth/login'), {
52
93
  method: 'POST',
53
- headers: { 'Content-Type': 'application/json' },
94
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
54
95
  body: JSON.stringify({ username, password }),
55
96
  });
56
97
 
98
+ const text = await response.text();
99
+
57
100
  if (!response.ok) {
58
- const text = await response.text();
59
101
  throw new Error(`Umami login failed: ${response.status} ${text}`);
60
102
  }
61
103
 
62
- const setCookie = response.headers.get('set-cookie') || '';
63
104
  let json: any = null;
64
105
  try {
65
- json = await response.json();
106
+ json = text ? JSON.parse(text) : null;
66
107
  } catch {
67
- // Some Umami versions rely on cookies only.
108
+ // ignore
68
109
  }
69
110
 
70
111
  const bearer = json?.token || json?.accessToken || json?.data?.token || json?.data?.accessToken;
112
+ const setCookie = response.headers.get('set-cookie') || '';
113
+
71
114
  const auth: UmamiAuth = bearer
72
115
  ? { headers: { Authorization: `Bearer ${bearer}` } }
73
116
  : { headers: setCookie ? { Cookie: setCookie.split(';')[0] } : {} };
@@ -76,33 +119,56 @@ async function getAuth(env: Env): Promise<UmamiAuth> {
76
119
  return auth;
77
120
  }
78
121
 
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
+ }
135
+
136
+ function numberParam(value: unknown, fallback: number) {
137
+ const parsed = Number(value);
138
+ return Number.isFinite(parsed) ? parsed : fallback;
139
+ }
140
+
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 });
149
+ }
150
+
79
151
  async function umamiGet(env: Env, path: string) {
80
- const baseUrl = required(env, 'UMAMI_URL');
81
152
  const auth = await getAuth(env);
82
- const response = await fetch(`${baseUrl}${path}`, {
153
+ const response = await fetch(apiUrl(env, path), {
83
154
  headers: {
84
155
  Accept: 'application/json',
85
156
  ...auth.headers,
86
157
  },
87
158
  });
88
159
 
160
+ const text = await response.text();
161
+
89
162
  if (!response.ok) {
90
- const text = await response.text();
91
163
  throw new Error(`Umami request failed: ${response.status} ${text}`);
92
164
  }
93
165
 
94
- return response.json();
166
+ return text ? JSON.parse(text) : null;
95
167
  }
96
168
 
97
- function metricPath(websiteId: string, type: string, startAt: number, endAt: number, limit = 10) {
98
- const params = new URLSearchParams({
99
- startAt: String(Math.round(startAt)),
100
- endAt: String(Math.round(endAt)),
101
- type,
102
- limit: String(limit),
103
- });
104
- return `/api/websites/${websiteId}/metrics?${params.toString()}`;
105
- }
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;
106
172
 
107
173
  export default (router: any, context: any) => {
108
174
  const env: Env = context.env || process.env;
@@ -111,29 +177,110 @@ export default (router: any, context: any) => {
111
177
  res.json({ ok: true, service: 'umami-analytics' });
112
178
  });
113
179
 
114
- router.get('/summary', async (req: any, res: any) => {
180
+ router.get('/debug', async (_req: any, res: any) => {
115
181
  try {
116
- const websiteId = String(req.query.websiteId || env.UMAMI_WEBSITE_ID || '');
117
- if (!websiteId) return res.status(400).json({ error: 'Missing websiteId or UMAMI_WEBSITE_ID' });
118
-
119
- const { startAt, endAt, days } = getRange(req.query);
120
- const key = `summary:${websiteId}:${startAt}:${endAt}`;
121
- const hit = cached(key);
122
- if (hit) return res.json(hit);
123
-
124
- const params = new URLSearchParams({ startAt: String(Math.round(startAt)), endAt: String(Math.round(endAt)) });
125
- const [stats, pages, referrers, events] = await Promise.all([
126
- umamiGet(env, `/api/websites/${websiteId}/stats?${params.toString()}`),
127
- umamiGet(env, metricPath(websiteId, 'url', startAt, endAt, 10)),
128
- umamiGet(env, metricPath(websiteId, 'referrer', startAt, endAt, 10)),
129
- umamiGet(env, metricPath(websiteId, 'event', startAt, endAt, 10)).catch(() => []),
130
- ]);
131
-
132
- const payload = { websiteId, range: { startAt, endAt, days }, stats, pages, referrers, events };
133
- setCached(key, payload);
134
- res.json(payload);
182
+ const authMode = env.UMAMI_API_KEY
183
+ ? 'api-key'
184
+ : env.UMAMI_TOKEN
185
+ ? 'bearer-token'
186
+ : env.UMAMI_USER_ID || env.UMAMI_API_CLIENT_USER_ID
187
+ ? 'api-client'
188
+ : 'username-password';
189
+
190
+ const me = await umamiGet(env, '/me').catch((error: any) => ({ error: error.message }));
191
+
192
+ res.json({
193
+ ok: true,
194
+ authMode,
195
+ umamiUrl: cleanBaseUrl(env),
196
+ websiteId: env.UMAMI_WEBSITE_ID,
197
+ me,
198
+ });
135
199
  } catch (error: any) {
136
200
  res.status(500).json({ error: error.message || 'Unknown error' });
137
201
  }
138
202
  });
203
+
204
+ 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({
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
+ });
139
286
  };