@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 +1 -1
- package/package.json +1 -1
- package/src/umami-analytics-endpoint/index.ts +210 -63
package/dist/api.js
CHANGED
|
@@ -1 +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};
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
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
|
|
24
|
-
|
|
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
|
|
38
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
106
|
+
json = text ? JSON.parse(text) : null;
|
|
66
107
|
} catch {
|
|
67
|
-
//
|
|
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(
|
|
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
|
|
166
|
+
return text ? JSON.parse(text) : null;
|
|
95
167
|
}
|
|
96
168
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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('/
|
|
180
|
+
router.get('/debug', async (_req: any, res: any) => {
|
|
115
181
|
try {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
};
|