1import { env } from '$env/dynamic/private';
2import { scopeCookies } from '$lib';
3import type { Cookies } from '@sveltejs/kit';
4import { nanoid } from 'nanoid';
5import { get, writable } from 'svelte/store';
6
7const visitCountFile = `${env.WEBSITE_DATA_DIR}/visitcount`;
8const readVisitCount = async () => {
9 try {
10 return parseInt(await Deno.readTextFile(visitCountFile));
11 } catch {
12 return 0;
13 }
14};
15export const visitCount = writable(await readVisitCount());
16
17export type Visitor = { visits: number[] };
18export const lastVisitors = writable<Map<string, Visitor>>(new Map());
19const VISITOR_EXPIRY_SECONDS = 60 * 60; // an hour seems reasonable
20
21export const decrementVisitCount = () => {
22 visitCount.set(get(visitCount) - 1);
23};
24
25export const incrementVisitCount = async (request: Request, cookies: Cookies) => {
26 let currentVisitCount = get(visitCount);
27 // check whether the request is from a bot or not (this doesnt need to be accurate we just want to filter out honest bots)
28 if (isBot(request)) return false;
29 const scopedCookies = scopeCookies(cookies, '/');
30 // parse the last visit timestamp from cookies if it exists
31 const visitedTimestamp = parseInt(scopedCookies.get('visitedTimestamp') || '0');
32 // get unix timestamp
33 const currentTime = Date.now();
34 const timeSinceVisit = currentTime - visitedTimestamp;
35 // check if this is the first time a client is visiting or if an hour has passed since they last visited
36 if (visitedTimestamp === 0 || timeSinceVisit > 1000 * 60 * 60 * 24) {
37 // increment current and write to the store
38 currentVisitCount += 1;
39 visitCount.set(currentVisitCount);
40 // update the cookie with the current timestamp
41 scopedCookies.set('visitedTimestamp', currentTime.toString());
42 // write the visit count to a file so we can load it later again
43 await Deno.writeTextFile(visitCountFile, currentVisitCount.toString());
44 }
45 return true;
46};
47
48export const removeLastVisitor = (id: string) => {
49 const visitors = get(lastVisitors);
50 if (visitors.has(id)) {
51 const visitor = visitors.get(id) ?? { visits: [] };
52 visitor?.visits.shift();
53 // if not enough visits remove
54 if (visitor?.visits.length === 0) {
55 visitors.delete(id);
56 } else {
57 visitors.set(id, visitor);
58 }
59 }
60 lastVisitors.set(visitors);
61};
62
63export const addLastVisitor = (request: Request, cookies: Cookies) => {
64 const { visitors, visitorId } = _addLastVisitor(get(lastVisitors), request, cookies);
65 lastVisitors.set(visitors);
66 return visitorId;
67};
68
69export const getVisitorId = (cookies: Cookies) => {
70 const scopedCookies = scopeCookies(cookies, '/');
71 // parse the last visit timestamp from cookies if it exists
72 return scopedCookies.get('visitorId');
73};
74
75// why not use this for incrementVisitCount? cuz i wanna have separate visit counts (one per hour and one per day, per hour being recent visitors)
76const _addLastVisitor = (visitors: Map<string, Visitor>, request: Request, cookies: Cookies) => {
77 const currentTime = Date.now();
78 // filter out old entries
79 visitors.forEach((visitor, id, map) => {
80 if (currentTime - visitor.visits[0] > 1000 * VISITOR_EXPIRY_SECONDS) map.delete(id);
81 else {
82 visitor.visits = visitor.visits.filter((since) => {
83 return currentTime - since < 1000 * VISITOR_EXPIRY_SECONDS;
84 });
85 map.set(id, visitor);
86 }
87 });
88 // check whether the request is from a bot or not (this doesnt need to be accurate we just want to filter out honest bots)
89 if (isBot(request)) return { visitors, visitorId: null };
90 const scopedCookies = scopeCookies(cookies, '/');
91 // parse the last visit timestamp from cookies if it exists
92 let visitorId = scopedCookies.get('visitorId') || '';
93 // if no such id exists, create one and assign it to the client
94 if (!visitors.has(visitorId)) {
95 visitorId = nanoid();
96 scopedCookies.set('visitorId', visitorId);
97 console.log(`new client visitor id ${visitorId}`);
98 }
99 // update the entry
100 const visitorEntry = visitors.get(visitorId) || { visits: [] };
101 // put new visit in the front
102 visitorEntry.visits = [currentTime].concat(visitorEntry.visits);
103 visitors.set(visitorId, visitorEntry);
104 return {
105 visitors,
106 visitorId
107 };
108};
109
110export const isBot = (request: Request) => {
111 const ua = request.headers.get('user-agent');
112 return ua
113 ? ua.toLowerCase().match(/(bot|crawl|spider|walk|fetch|scrap|proxy|image)/) !== null
114 : true;
115};
116
117export const notifyDarkVisitors = (url: URL, request: Request) => {
118 fetch('https://api.darkvisitors.com/visits', {
119 method: 'POST',
120 headers: {
121 authorization: `Bearer ${env.DARK_VISITORS_TOKEN}`,
122 'content-type': 'application/json'
123 },
124 body: JSON.stringify({
125 request_path: url.pathname,
126 request_method: request.method,
127 request_headers: request.headers
128 })
129 })
130 .catch((why) => {
131 console.log('failed sending dark visitors analytics:', why);
132 return null;
133 })
134 .then((resp) => {
135 if (resp !== null) {
136 const host = `(${request.headers.get('host')}|${request.headers.get('x-real-ip')}|${request.headers.get('user-agent')})`;
137 console.log(`sent visitor analytic to dark visitors: ${resp.statusText}; ${host}`);
138 }
139 });
140};