@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 +1 -1
- package/package.json +1 -1
- package/src/umami-analytics-endpoint/index.ts +108 -18
package/dist/api.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const
|
|
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,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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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 =
|
|
116
|
+
json = text ? JSON.parse(text) : null;
|
|
66
117
|
} catch {
|
|
67
|
-
//
|
|
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(
|
|
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
|
|
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 `/
|
|
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, `/
|
|
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(() => []),
|