@koendhoore/directus-extension-umami-analytics 1.0.0 → 1.0.1

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";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};
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.1",
4
4
  "description": "Directus bundle: secure Umami proxy endpoint + native Insights analytics panel.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,8 +1,9 @@
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
9
  const FIVE_MINUTES = 5 * 60 * 1000;
@@ -12,7 +13,17 @@ let authCache: { expires: number; auth: UmamiAuth } | null = null;
12
13
  function required(env: Env, key: string): string {
13
14
  const value = env[key];
14
15
  if (!value) throw new Error(`Missing ${key}`);
15
- return value.replace(/\/$/, '');
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
+ function apiUrl(env: Env, path: string): string {
25
+ const cleanPath = path.replace(/^\/api\/?/, '').replace(/^\/+/, '');
26
+ return `${cleanBaseUrl(env)}/api/${cleanPath}`;
16
27
  }
17
28
 
18
29
  function numberParam(value: unknown, fallback: number) {
@@ -38,36 +49,78 @@ function setCached(key: string, value: unknown, ttlMs = FIVE_MINUTES) {
38
49
  cache.set(key, { expires: Date.now() + ttlMs, value });
39
50
  }
40
51
 
41
- async function getAuth(env: Env): Promise<UmamiAuth> {
42
- const token = env.UMAMI_TOKEN || env.UMAMI_API_KEY;
43
- if (token) return { headers: { Authorization: `Bearer ${token}` } };
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');
58
+ }
44
59
 
60
+ function createJwt(payload: Record<string, unknown>, secret: string) {
61
+ const header = { alg: 'HS256', typ: 'JWT' };
62
+ const encodedHeader = base64url(JSON.stringify(header));
63
+ const encodedPayload = base64url(JSON.stringify(payload));
64
+ const data = `${encodedHeader}.${encodedPayload}`;
65
+ const signature = crypto.createHmac('sha256', secret).update(data).digest();
66
+ return `${data}.${base64url(signature)}`;
67
+ }
68
+
69
+ function encryptAesGcm(value: string, secret: string) {
70
+ const iv = crypto.randomBytes(16);
71
+ const salt = crypto.randomBytes(64);
72
+ const key = crypto.pbkdf2Sync(secret, salt, 10000, 32, 'sha512');
73
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
74
+ const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
75
+ const tag = cipher.getAuthTag();
76
+ return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
77
+ }
78
+
79
+ function createSecureToken(payload: Record<string, unknown>, secret: string) {
80
+ return encryptAesGcm(createJwt(payload, secret), secret);
81
+ }
82
+
83
+ function getApiClientAuth(env: Env): UmamiAuth | null {
84
+ const userId = env.UMAMI_USER_ID || env.UMAMI_API_CLIENT_USER_ID;
85
+ const appSecret = env.UMAMI_APP_SECRET || env.UMAMI_API_CLIENT_SECRET;
86
+
87
+ if (!userId || !appSecret) return null;
88
+
89
+ // This matches @umami/api-client: secret is sha512(APP_SECRET), then createSecureToken({ userId }, secret)
90
+ const secret = hash(appSecret);
91
+ const token = createSecureToken({ userId }, secret);
92
+
93
+ return { headers: { Authorization: `Bearer ${token}` } };
94
+ }
95
+
96
+ async function getUsernamePasswordAuth(env: Env): Promise<UmamiAuth> {
45
97
  if (authCache && authCache.expires > Date.now()) return authCache.auth;
46
98
 
47
- const baseUrl = required(env, 'UMAMI_URL');
48
99
  const username = required(env, 'UMAMI_USERNAME');
49
100
  const password = required(env, 'UMAMI_PASSWORD');
50
101
 
51
- const response = await fetch(`${baseUrl}/api/auth/login`, {
102
+ const response = await fetch(apiUrl(env, '/auth/login'), {
52
103
  method: 'POST',
53
- headers: { 'Content-Type': 'application/json' },
104
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
54
105
  body: JSON.stringify({ username, password }),
55
106
  });
56
107
 
108
+ const text = await response.text();
109
+
57
110
  if (!response.ok) {
58
- const text = await response.text();
59
111
  throw new Error(`Umami login failed: ${response.status} ${text}`);
60
112
  }
61
113
 
62
- const setCookie = response.headers.get('set-cookie') || '';
63
114
  let json: any = null;
64
115
  try {
65
- json = await response.json();
116
+ json = text ? JSON.parse(text) : null;
66
117
  } catch {
67
- // Some Umami versions rely on cookies only.
118
+ // ignore
68
119
  }
69
120
 
70
121
  const bearer = json?.token || json?.accessToken || json?.data?.token || json?.data?.accessToken;
122
+ const setCookie = response.headers.get('set-cookie') || '';
123
+
71
124
  const auth: UmamiAuth = bearer
72
125
  ? { headers: { Authorization: `Bearer ${bearer}` } }
73
126
  : { headers: setCookie ? { Cookie: setCookie.split(';')[0] } : {} };
@@ -76,22 +129,35 @@ async function getAuth(env: Env): Promise<UmamiAuth> {
76
129
  return auth;
77
130
  }
78
131
 
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
+
136
+ const token = env.UMAMI_TOKEN;
137
+ if (token) return { headers: { Authorization: `Bearer ${token}` } };
138
+
139
+ const apiClientAuth = getApiClientAuth(env);
140
+ if (apiClientAuth) return apiClientAuth;
141
+
142
+ return getUsernamePasswordAuth(env);
143
+ }
144
+
79
145
  async function umamiGet(env: Env, path: string) {
80
- const baseUrl = required(env, 'UMAMI_URL');
81
146
  const auth = await getAuth(env);
82
- const response = await fetch(`${baseUrl}${path}`, {
147
+ const response = await fetch(apiUrl(env, path), {
83
148
  headers: {
84
149
  Accept: 'application/json',
85
150
  ...auth.headers,
86
151
  },
87
152
  });
88
153
 
154
+ const text = await response.text();
155
+
89
156
  if (!response.ok) {
90
- const text = await response.text();
91
157
  throw new Error(`Umami request failed: ${response.status} ${text}`);
92
158
  }
93
159
 
94
- return response.json();
160
+ return text ? JSON.parse(text) : null;
95
161
  }
96
162
 
97
163
  function metricPath(websiteId: string, type: string, startAt: number, endAt: number, limit = 10) {
@@ -101,7 +167,7 @@ function metricPath(websiteId: string, type: string, startAt: number, endAt: num
101
167
  type,
102
168
  limit: String(limit),
103
169
  });
104
- return `/api/websites/${websiteId}/metrics?${params.toString()}`;
170
+ return `/websites/${websiteId}/metrics?${params.toString()}`;
105
171
  }
106
172
 
107
173
  export default (router: any, context: any) => {
@@ -111,6 +177,30 @@ export default (router: any, context: any) => {
111
177
  res.json({ ok: true, service: 'umami-analytics' });
112
178
  });
113
179
 
180
+ router.get('/debug', async (_req: any, res: any) => {
181
+ try {
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
+ });
199
+ } catch (error: any) {
200
+ res.status(500).json({ error: error.message || 'Unknown error' });
201
+ }
202
+ });
203
+
114
204
  router.get('/summary', async (req: any, res: any) => {
115
205
  try {
116
206
  const websiteId = String(req.query.websiteId || env.UMAMI_WEBSITE_ID || '');
@@ -123,7 +213,7 @@ export default (router: any, context: any) => {
123
213
 
124
214
  const params = new URLSearchParams({ startAt: String(Math.round(startAt)), endAt: String(Math.round(endAt)) });
125
215
  const [stats, pages, referrers, events] = await Promise.all([
126
- umamiGet(env, `/api/websites/${websiteId}/stats?${params.toString()}`),
216
+ umamiGet(env, `/websites/${websiteId}/stats?${params.toString()}`),
127
217
  umamiGet(env, metricPath(websiteId, 'url', startAt, endAt, 10)),
128
218
  umamiGet(env, metricPath(websiteId, 'referrer', startAt, endAt, 10)),
129
219
  umamiGet(env, metricPath(websiteId, 'event', startAt, endAt, 10)).catch(() => []),