├── .npmrc ├── example_tracking ├── functions │ ├── utils │ │ ├── salt.js │ │ └── languagecodes.js │ └── counter.js ├── netlify.toml ├── plugins │ └── netlify-plugin-handle-mysalt │ │ ├── manifest.yml │ │ └── index.js ├── package.json ├── public │ └── index.html └── .github │ └── workflows │ └── main.yml ├── src ├── app.css ├── lib │ ├── images │ │ └── logo.png │ ├── index.ts │ ├── components │ │ ├── BoxTopRow.svelte │ │ ├── ListItemLink.svelte │ │ └── ListItemBarChart.svelte │ ├── types.ts │ └── helper.ts ├── routes │ ├── +layout.svelte │ ├── logout │ │ └── +page.server.ts │ ├── +page.svelte │ └── stats │ │ ├── +page.ts │ │ └── +page.svelte ├── app.d.ts ├── app.html └── stores │ └── dataStore.ts ├── static ├── favicon.png └── _redirects ├── .gitattributes ├── .prettierignore ├── postcss.config.js ├── vite.config.ts ├── netlify.toml ├── tailwind.config.ts ├── functions ├── fauna │ └── client.ts ├── getHitsOverall.ts ├── getHitsByDate.ts ├── getHitsByYear.ts ├── getHitsByDateUnique.ts ├── getHitsByYearByMonth.ts ├── getHitsByOSOverall.ts ├── getHitsByUrlOverall.ts ├── getHitsByLanguageOverall.ts ├── getHitsByBrowserOverall.ts ├── getHitsByOSByYear.ts ├── getHitsByUrlByYear.ts ├── getHitsByLanguageByYear.ts ├── getHitsByBrowserByYear.mts └── login.ts ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── svelte.config.js ├── eslint.config.js ├── package.json ├── LICENSE └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /example_tracking/functions/utils/salt.js: -------------------------------------------------------------------------------- 1 | module.exports.salt = '' -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pa-nic/vidu/HEAD/static/favicon.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /src/lib/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pa-nic/vidu/HEAD/src/lib/images/logo.png -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /static/_redirects: -------------------------------------------------------------------------------- 1 | / /stats/ 302! Role=stats 2 | 3 | /stats/* /.netlify/functions/sveltekit-render 200! Role=stats 4 | 5 | /stats/* / 302! -------------------------------------------------------------------------------- /example_tracking/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "public" 3 | functions = "./functions" 4 | 5 | [[plugins]] 6 | package = "/plugins/netlify-plugin-handle-mysalt" -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | functions = "functions" 4 | publish = "public" 5 | 6 | [template.environment] 7 | FAUNA_SECRET = "change me for your secret fauna key" -------------------------------------------------------------------------------- /example_tracking/plugins/netlify-plugin-handle-mysalt/manifest.yml: -------------------------------------------------------------------------------- 1 | name: netlify-plugin-handle-mysalt 2 | inputs: 3 | - name: filename 4 | description: Filename of salt js file 5 | default: "salt" -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 | 8 |
9 |
-------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | export default { 4 | content: ['./src/**/*.{html,js,svelte,ts}'], 5 | 6 | theme: { 7 | extend: {} 8 | }, 9 | 10 | plugins: [] 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /functions/fauna/client.ts: -------------------------------------------------------------------------------- 1 | import { Client, fql } from "fauna"; 2 | 3 | const client = new Client({ 4 | secret: process.env.FAUNA_SECRET, 5 | // keepAlive: false 6 | }); 7 | 8 | export default { 9 | client, 10 | fql 11 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /example_tracking/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "name": "Patrick Probst", 4 | "url": "https://8028.de" 5 | }, 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/pa-nic/vidu" 9 | }, 10 | "dependencies": { 11 | "bowser": "^2.11.0", 12 | "fauna": "^2.3.0", 13 | "fs": "0.0.1-security" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | /.svelte-kit 7 | /build 8 | 9 | # OS 10 | .DS_Store 11 | Thumbs.db 12 | 13 | # Env 14 | .env 15 | .env.* 16 | !.env.example 17 | !.env.test 18 | 19 | # Vite 20 | vite.config.js.timestamp-* 21 | vite.config.ts.timestamp-* 22 | 23 | # Local Netlify folder 24 | .netlify 25 | -------------------------------------------------------------------------------- /src/routes/logout/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit' 2 | 3 | export const actions = { 4 | default({ cookies }) { 5 | // eat the cookie 6 | cookies.set('nf_jwt', '', { 7 | path: '/', 8 | expires: new Date(0), 9 | }) 10 | 11 | // redirect the user 12 | redirect(302, '/') 13 | }, 14 | } -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/components/BoxTopRow.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 |

{heading}

10 |

11 | {formatNumber(data)} 12 | hits 13 |

14 |
15 | -------------------------------------------------------------------------------- /example_tracking/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example page to track 6 | 7 | 8 |

Example

9 |

Look into the code to see the tracker snippet implementation

10 | 17 | 18 | -------------------------------------------------------------------------------- /functions/getHitsOverall.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsOverall` invoked"); 4 | 5 | export async function handler() { 6 | 7 | // Get overall hits 8 | const query = fauna.fql`hits.all().count()`; 9 | try { 10 | const response = await fauna.client.query(query); 11 | return { 12 | statusCode: 200, 13 | body: JSON.stringify(response) 14 | }; 15 | } catch (error) { 16 | console.error(error); 17 | return { 18 | statusCode: 400, 19 | body: JSON.stringify(error) 20 | }; 21 | } 22 | } -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface tHitsByDay { 2 | day: string, 3 | hits: number 4 | } 5 | 6 | export interface tHitsByMonth { 7 | month: string, 8 | hits: number 9 | } 10 | 11 | export interface tHitsByYear { 12 | year: number, 13 | hits: number 14 | } 15 | 16 | export interface tHitsByUrl { 17 | url: string, 18 | hits: number 19 | } 20 | 21 | export interface tHitsByBrowser { 22 | browser: string, 23 | hits: number 24 | } 25 | 26 | export interface tHitsByLanguage { 27 | language: string, 28 | hits: number 29 | } 30 | 31 | export interface tHitsByOS { 32 | os: string, 33 | hits: number 34 | } -------------------------------------------------------------------------------- /functions/getHitsByDate.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByDate` invoked"); 4 | 5 | export async function handler(event) { 6 | 7 | // Get hits by date 8 | const query = fauna.fql`hits.byDate(Date.fromString(${event.queryStringParameters.date})).count()`; 9 | try { 10 | const response = await fauna.client.query(query); 11 | return { 12 | statusCode: 200, 13 | body: JSON.stringify(response) 14 | }; 15 | } catch (error) { 16 | console.error(error); 17 | return { 18 | statusCode: 400, 19 | body: JSON.stringify(error) 20 | }; 21 | } 22 | } -------------------------------------------------------------------------------- /src/lib/components/ListItemLink.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 |
14 | 18 | {pathname} 19 | 20 |
21 |
22 | {formatNumber(value)} 23 |
24 |
-------------------------------------------------------------------------------- /functions/getHitsByYear.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByYear` invoked"); 4 | 5 | export async function handler(event) { 6 | 7 | // Get hits by year 8 | const year = Number(event.queryStringParameters.year); 9 | const query = fauna.fql`hits.byYear(${year}).count()`; 10 | try { 11 | const response = await fauna.client.query(query); 12 | return { 13 | statusCode: 200, 14 | body: JSON.stringify(response) 15 | }; 16 | } catch (error) { 17 | console.error(error); 18 | return { 19 | statusCode: 400, 20 | body: JSON.stringify(error) 21 | }; 22 | } 23 | } -------------------------------------------------------------------------------- /functions/getHitsByDateUnique.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByDateUnique` invoked"); 4 | 5 | export async function handler(event) { 6 | 7 | // Get hits by date 8 | const query = fauna.fql`(hits.byDate(Date.fromString(${event.queryStringParameters.date})) {usr_hash}).distinct().count()`; 9 | try { 10 | const response = await fauna.client.query(query); 11 | return { 12 | statusCode: 200, 13 | body: JSON.stringify(response) 14 | }; 15 | } catch (error) { 16 | console.error(error); 17 | return { 18 | statusCode: 400, 19 | body: JSON.stringify(error) 20 | }; 21 | } 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 15 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://svelte.dev/docs/kit/integrations 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 | // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-config-prettier'; 2 | import js from '@eslint/js'; 3 | import svelte from 'eslint-plugin-svelte'; 4 | import globals from 'globals'; 5 | import ts from 'typescript-eslint'; 6 | 7 | export default ts.config( 8 | js.configs.recommended, 9 | ...ts.configs.recommended, 10 | ...svelte.configs['flat/recommended'], 11 | prettier, 12 | ...svelte.configs['flat/prettier'], 13 | { 14 | languageOptions: { 15 | globals: { 16 | ...globals.browser, 17 | ...globals.node 18 | } 19 | } 20 | }, 21 | { 22 | files: ['**/*.svelte'], 23 | 24 | languageOptions: { 25 | parserOptions: { 26 | parser: ts.parser 27 | } 28 | } 29 | }, 30 | { 31 | ignores: ['build/', '.svelte-kit/', 'dist/'] 32 | } 33 | ); 34 | -------------------------------------------------------------------------------- /functions/getHitsByYearByMonth.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByYearByMonth` invoked"); 4 | 5 | export async function handler(event) { 6 | 7 | // Get hits by year and month 8 | const year = Number(event.queryStringParameters.year); 9 | const month = Number(event.queryStringParameters.month); 10 | const query = fauna.fql`hits.byYearByMonth(${year},${month}).count()`; 11 | try { 12 | const response = await fauna.client.query(query); 13 | return { 14 | statusCode: 200, 15 | body: JSON.stringify(response) 16 | }; 17 | } catch (error) { 18 | console.error(error); 19 | return { 20 | statusCode: 400, 21 | body: JSON.stringify(error) 22 | }; 23 | } 24 | } -------------------------------------------------------------------------------- /src/lib/components/ListItemBarChart.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 |
18 |
19 |
{description}
20 |
21 |
22 | {formatNumber(value)} 23 |
24 |
-------------------------------------------------------------------------------- /example_tracking/.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | schedule: 9 | # Run at 0:00 daily 10 | - cron: '0 0 * * *' 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | # This workflow contains a single job called "build" 18 | build: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | 22 | # Steps represent a sequence of tasks that will be executed as part of the job 23 | steps: 24 | # Runs a single command using the runners shell 25 | - name: Curl request 26 | run: curl -X POST -d 'refreshsalt' &trigger_title=Refreshing+salt+by+github+webhook 27 | -------------------------------------------------------------------------------- /functions/getHitsByOSOverall.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByOSOverall` invoked"); 4 | 5 | export async function handler() { 6 | 7 | // Get hits by OS overall sorted by hits and limited to top 10 8 | // TODO: Do it in one "fold()" 9 | const query = fauna.fql`Object.entries(hits.all().fold({}, (accumulator, value) => { 10 | let count = (accumulator[value.os] ?? 0) + 1 11 | let override = Object.fromEntries([[value.os, count]]) 12 | Object.assign(accumulator, override)})).fold([], (acc, elm) => { 13 | let obj = Object.fromEntries([['os', elm.first()], ["hits", elm.last()]]) 14 | acc.append(obj) 15 | }).order(desc(.hits)).take(10)`; 16 | 17 | try { 18 | const response = await fauna.client.query(query); 19 | return { 20 | statusCode: 200, 21 | body: JSON.stringify(response) 22 | }; 23 | } catch (error) { 24 | console.error(error); 25 | return { 26 | statusCode: 400, 27 | body: JSON.stringify(error) 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /functions/getHitsByUrlOverall.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByUrlOverall` invoked"); 4 | 5 | export async function handler() { 6 | 7 | // Get hits by URL overall sorted by hits and limited to top 10 8 | // TODO: Do it in one "fold()" 9 | const query = fauna.fql`Object.entries(hits.all().fold({}, (accumulator, value) => { 10 | let count = (accumulator[value.url] ?? 0) + 1 11 | let override = Object.fromEntries([[value.url, count]]) 12 | Object.assign(accumulator, override)})).fold([], (acc, elm) => { 13 | let obj = Object.fromEntries([['url', elm.first()], ["hits", elm.last()]]) 14 | acc.append(obj) 15 | }).order(desc(.hits)).take(10)`; 16 | 17 | try { 18 | const response = await fauna.client.query(query); 19 | return { 20 | statusCode: 200, 21 | body: JSON.stringify(response) 22 | }; 23 | } catch (error) { 24 | console.error(error); 25 | return { 26 | statusCode: 400, 27 | body: JSON.stringify(error) 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /functions/getHitsByLanguageOverall.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByLanguageOverall` invoked"); 4 | 5 | export async function handler() { 6 | 7 | // Get hits by language overall sorted by hits and limited to top 10 8 | // TODO: Do it in one "fold()" 9 | const query = fauna.fql`Object.entries(hits.all().fold({}, (accumulator, value) => { 10 | let count = (accumulator[value.language] ?? 0) + 1 11 | let override = Object.fromEntries([[value.language, count]]) 12 | Object.assign(accumulator, override)})).fold([], (acc, elm) => { 13 | let obj = Object.fromEntries([['language', elm.first()], ["hits", elm.last()]]) 14 | acc.append(obj) 15 | }).order(desc(.hits)).take(10)`; 16 | 17 | try { 18 | const response = await fauna.client.query(query); 19 | return { 20 | statusCode: 200, 21 | body: JSON.stringify(response) 22 | }; 23 | } catch (error) { 24 | console.error(error); 25 | return { 26 | statusCode: 400, 27 | body: JSON.stringify(error) 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /functions/getHitsByBrowserOverall.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByBrowserOverall` invoked"); 4 | 5 | export async function handler() { 6 | 7 | // Get hits by browser overall sorted by hits and limited to top 10 8 | // TODO: Do it in one "fold()" 9 | const query = fauna.fql`Object.entries(hits.all().fold({}, (accumulator, value) => { 10 | let count = (accumulator[value.browser_name] ?? 0) + 1 11 | let override = Object.fromEntries([[value.browser_name, count]]) 12 | Object.assign(accumulator, override)})).fold([], (acc, elm) => { 13 | let obj = Object.fromEntries([['browser', elm.first()], ["hits", elm.last()]]) 14 | acc.append(obj) 15 | }).order(desc(.hits)).take(10)`; 16 | 17 | try { 18 | const response = await fauna.client.query(query); 19 | return { 20 | statusCode: 200, 21 | body: JSON.stringify(response) 22 | }; 23 | } catch (error) { 24 | console.error(error); 25 | return { 26 | statusCode: 400, 27 | body: JSON.stringify(error) 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /functions/getHitsByOSByYear.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByOSByYear` invoked"); 4 | 5 | export async function handler(event) { 6 | 7 | // Get hits by OS by year sorted by hits and limited to top 10 8 | // TODO: Do it in one "fold()" 9 | const query = fauna.fql`Object.entries(hits.byYear(Date.fromString(${event.queryStringParameters.date}).year).fold({}, (accumulator, value) => { 10 | let count = (accumulator[value.os] ?? 0) + 1 11 | let override = Object.fromEntries([[value.os, count]]) 12 | Object.assign(accumulator, override)})).fold([], (acc, elm) => { 13 | let obj = Object.fromEntries([['os', elm.first()], ["hits", elm.last()]]) 14 | acc.append(obj) 15 | }).order(desc(.hits)).take(10)`; 16 | 17 | try { 18 | const response = await fauna.client.query(query); 19 | return { 20 | statusCode: 200, 21 | body: JSON.stringify(response) 22 | }; 23 | } catch (error) { 24 | console.error(error); 25 | return { 26 | statusCode: 400, 27 | body: JSON.stringify(error) 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /functions/getHitsByUrlByYear.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByUrlByYear` invoked"); 4 | 5 | export async function handler(event) { 6 | 7 | // Get hits by URL by year sorted by hits and limited to top 10 8 | // TODO: Do it in one "fold()" 9 | const query = fauna.fql`Object.entries(hits.byYear(Date.fromString(${event.queryStringParameters.date}).year).fold({}, (accumulator, value) => { 10 | let count = (accumulator[value.url] ?? 0) + 1 11 | let override = Object.fromEntries([[value.url, count]]) 12 | Object.assign(accumulator, override)})).fold([], (acc, elm) => { 13 | let obj = Object.fromEntries([['url', elm.first()], ["hits", elm.last()]]) 14 | acc.append(obj) 15 | }).order(desc(.hits)).take(10)`; 16 | 17 | try { 18 | const response = await fauna.client.query(query); 19 | return { 20 | statusCode: 200, 21 | body: JSON.stringify(response) 22 | }; 23 | } catch (error) { 24 | console.error(error); 25 | return { 26 | statusCode: 400, 27 | body: JSON.stringify(error) 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /functions/getHitsByLanguageByYear.ts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByLanguageByYear` invoked"); 4 | 5 | export async function handler(event) { 6 | 7 | // Get hits by language by year sorted by hits and limited to top 10 8 | // TODO: Do it in one "fold()" 9 | const query = fauna.fql`Object.entries(hits.byYear(Date.fromString(${event.queryStringParameters.date}).year).fold({}, (accumulator, value) => { 10 | let count = (accumulator[value.language] ?? 0) + 1 11 | let override = Object.fromEntries([[value.language, count]]) 12 | Object.assign(accumulator, override)})).fold([], (acc, elm) => { 13 | let obj = Object.fromEntries([['language', elm.first()], ["hits", elm.last()]]) 14 | acc.append(obj) 15 | }).order(desc(.hits)).take(10)`; 16 | 17 | try { 18 | const response = await fauna.client.query(query); 19 | return { 20 | statusCode: 200, 21 | body: JSON.stringify(response) 22 | }; 23 | } catch (error) { 24 | console.error(error); 25 | return { 26 | statusCode: 400, 27 | body: JSON.stringify(error) 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /functions/getHitsByBrowserByYear.mts: -------------------------------------------------------------------------------- 1 | import fauna from "./fauna/client"; 2 | 3 | console.log("Function `getHitsByBrowserByYear` invoked"); 4 | 5 | export async function handler(event) { 6 | 7 | // Get hits by browser by year sorted by hits and limited to top 10 8 | // TODO: Do it in one "fold()" 9 | const query = fauna.fql`Object.entries(hits.byYear(Date.fromString(${event.queryStringParameters.date}).year).fold({}, (accumulator, value) => { 10 | let count = (accumulator[value.browser_name] ?? 0) + 1 11 | let override = Object.fromEntries([[value.browser_name, count]]) 12 | Object.assign(accumulator, override)})).fold([], (acc, elm) => { 13 | let obj = Object.fromEntries([['browser', elm.first()], ["hits", elm.last()]]) 14 | acc.append(obj) 15 | }).order(desc(.hits)).take(10)`; 16 | 17 | try { 18 | const response = await fauna.client.query(query); 19 | return { 20 | statusCode: 200, 21 | body: JSON.stringify(response) 22 | }; 23 | } catch (error) { 24 | console.error(error); 25 | return { 26 | statusCode: 400, 27 | body: JSON.stringify(error) 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stats", 3 | "version": "1.0.2", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "format": "prettier --write .", 12 | "lint": "prettier --check . && eslint ." 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/adapter-auto": "^3.0.0", 16 | "@sveltejs/kit": "^2.8.1", 17 | "@sveltejs/vite-plugin-svelte": "^4.0.1", 18 | "@types/eslint": "^9.6.1", 19 | "autoprefixer": "^10.4.20", 20 | "eslint": "^9.15.0", 21 | "eslint-config-prettier": "^9.1.0", 22 | "eslint-plugin-svelte": "^2.36.0", 23 | "globals": "^15.0.0", 24 | "prettier": "^3.3.2", 25 | "prettier-plugin-svelte": "^3.2.8", 26 | "prettier-plugin-tailwindcss": "^0.6.5", 27 | "svelte": "^5.2.2", 28 | "svelte-check": "^4.0.9", 29 | "tailwindcss": "^3.4.15", 30 | "typescript": "^5.0.0", 31 | "typescript-eslint": "^8.14.0", 32 | "vite": "^5.4.11" 33 | }, 34 | "dependencies": { 35 | "@netlify/functions": "^2.8.2", 36 | "fauna": "^2.3.0", 37 | "svelte-loading-spinners": "^0.3.6" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /src/stores/dataStore.ts: -------------------------------------------------------------------------------- 1 | import type { tHitsByBrowser, tHitsByDay, tHitsByLanguage, tHitsByMonth, tHitsByYear, tHitsByOS, tHitsByUrl } from "$lib/types"; 2 | import { writable } from 'svelte/store'; 3 | 4 | export const hitsTodayUnique = writable(0); 5 | export const hitsOverall = writable(0); 6 | export const hitsByDay = writable>(); 7 | export const hitsByMonth = writable>(); 8 | export const hitsByYear = writable>(); 9 | export const hitsByUrlOverall = writable>(); 10 | export const hitsByUrlCurrYear = writable>(); 11 | export const hitsByBrowserOverall = writable>(); 12 | export const hitsByBrowserCurrYear = writable>(); 13 | export const hitsByLanguageOverall = writable>(); 14 | export const hitsByLanguageCurrYear = writable>(); 15 | export const hitsByOSOverall = writable>(); 16 | export const hitsByOSCurrYear = writable>(); 17 | 18 | 19 | // Enable/disable loading spinners 20 | export const fetchingHitsByDate = writable(false); 21 | export const fetchingHitsByUrl = writable(false); 22 | export const fetchingHitsByBrowser = writable(false); 23 | export const fetchingHitsByLanguage = writable(false); 24 | export const fetchingHitsByOS = writable(false); -------------------------------------------------------------------------------- /src/lib/helper.ts: -------------------------------------------------------------------------------- 1 | export function validateEmail(email: string) { 2 | const rx = RegExp(/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i); 3 | return rx.test(email); 4 | } 5 | 6 | export function formatNumber(number: number) { 7 | const SI_POSTFIXES = ["", "k", "M", "G", "T", "P", "E"]; 8 | // what tier? (determines SI prefix) 9 | const tier = (Math.log10(Math.abs(number)) / 3) | 0; 10 | // if zero, we don't need a prefix 11 | if (tier == 0) return number; 12 | // get postfix and determine scale 13 | const postfix = SI_POSTFIXES[tier]; 14 | const scale = Math.pow(10, tier * 3); 15 | // scale the number 16 | const scaled = number / scale; 17 | // format number 18 | let formatted = scaled.toPrecision(3); 19 | // remove '.0' case 20 | if (/\.0$/.test(formatted)) 21 | formatted = formatted.substr(0, formatted.length - 2); 22 | // return with added postfix 23 | return formatted + postfix; 24 | } 25 | 26 | export function getNameOfDay(dayOfWeek: number) { 27 | const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; 28 | return days[dayOfWeek]; 29 | } 30 | 31 | export function getNameOfMonth(monthOfYear: number) { 32 | const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; 33 | return months[monthOfYear]; 34 | } 35 | 36 | export function getMaxOfArray(arr: []) { 37 | return Math.max(...arr); 38 | } -------------------------------------------------------------------------------- /functions/login.ts: -------------------------------------------------------------------------------- 1 | export async function handler(event) { 2 | const formData = new URLSearchParams (event.body); 3 | const email = formData.get("email")?.toString() ?? ''; 4 | const password = formData.get("password")?.toString() ?? ''; 5 | 6 | const endpoint = `${process.env.URL}/.netlify/identity/token`; 7 | 8 | const data = new URLSearchParams(); 9 | data.append('grant_type', 'password'); 10 | data.append('username', email); 11 | data.append('password', password); 12 | const options = { 13 | headers: { 14 | 'Content-Type': 'application/x-www-form-urlencoded', 15 | }, 16 | }; 17 | 18 | try { 19 | const response = await fetch(`${process.env.URL}/.netlify/identity/token`, { 20 | method: 'POST', 21 | headers: { 22 | 'Content-Type': 'application/x-www-form-urlencoded' 23 | }, 24 | body: data 25 | }) 26 | const access_token = (await response.json()).access_token; 27 | 28 | return { 29 | statusCode: 302, 30 | headers: { 31 | 'Set-Cookie': `nf_jwt=${access_token}; Path=/; HttpOnly; Secure`, 32 | 'Cache-Control': 'no-cache', 33 | Location: '/stats/', 34 | }, 35 | }; 36 | } catch (error) { 37 | console.log(error); 38 | return { 39 | statusCode: 302, 40 | headers: { 41 | 'Cache-Control': 'no-cache', 42 | Location: '/', 43 | }, 44 | }; 45 | } 46 | }; -------------------------------------------------------------------------------- /example_tracking/plugins/netlify-plugin-handle-mysalt/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const crypto = require('crypto'); 3 | 4 | // Salt file name defined in netlify.toml 5 | const getResourcesFile = ({ inputs }) => { 6 | return `./functions/utils/${inputs.filename}.js`; 7 | } 8 | 9 | // Create new salt 10 | const createNewSalt = () => { 11 | let salt = crypto.randomBytes(16).toString('hex'); 12 | let content = `module.exports.salt = '${salt}'`; 13 | return content; 14 | } 15 | 16 | module.exports = { 17 | async onPreBuild({ utils, inputs }) { 18 | const file = getResourcesFile({ inputs }); 19 | if (process.env.INCOMING_HOOK_BODY == 'refreshsalt') { 20 | // Create file with new salt on scheduled build hook 21 | fs.writeFileSync(file,createNewSalt()); 22 | console.log(`Created file "${file}" with updated salt`); 23 | } else { 24 | // Restore cached salt file on defaul build 25 | const success = await utils.cache.restore(file); 26 | console.log(`Checking if file ${file} exists`); 27 | 28 | if (success) { 29 | console.log(`Restored ${file} from cached`); 30 | } else { 31 | // If restoring cached file fails, create new 32 | console.log(`No cache found for requested file. Create new salt file`); 33 | fs.writeFileSync(file,createNewSalt()); 34 | console.log(`Created new file "${file}" with salt`); 35 | } 36 | } 37 | }, 38 | 39 | async onPostBuild({ utils, inputs }) { 40 | const file = getResourcesFile({ inputs }); 41 | // Cache salt file 42 | const success = await utils.cache.save(file); 43 | if (success) { 44 | console.log(`Saved ${file} file to cache`); 45 | } else { 46 | console.log(`No file cached`); 47 | } 48 | } 49 | }; -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 |
12 |
13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 | 28 |
29 |
-------------------------------------------------------------------------------- /example_tracking/functions/counter.js: -------------------------------------------------------------------------------- 1 | const { Client, fql } = require('fauna'); 2 | const bowser = require('bowser'); 3 | const crypto = require('crypto'); 4 | const language = require("./utils/languagecodes"); 5 | const { salt } = require('./utils/salt'); 6 | 7 | exports.handler = async (event) => { 8 | const { headers } = event; 9 | // We will use the referer to know which page we want to track. 10 | const referer = headers['referer']; 11 | const url = new URL(referer); 12 | const { hostname } = url; 13 | const { pathname } = url; 14 | // User agend header parsing 15 | const useragent = headers['user-agent']; 16 | // Client IP 17 | let clientIP = headers['x-nf-client-connection-ip']; 18 | 19 | /* BEGIN # Track only if NOT bot/crawler, localhost and netlify deploy server */ 20 | 21 | if ( !(/bot|crawler|HeadlessChrome|spider|crawling/i).test(useragent) && clientIP !== '127.0.0.1' && clientIP !== '::1') { 22 | const browser = bowser.getParser(useragent); 23 | // Create browser details object 24 | const clientBrowser = browser.getBrowser(); 25 | // // Create OS details object 26 | const clientOS = browser.getOS(); 27 | // // Decode client language 28 | const clientLanguage = language[headers['accept-language'].substring(0,2)]; 29 | // Anonymize IPv4 by removing the last byte 30 | if (clientIP.includes('.')) { 31 | clientIP = clientIP.substring(0, clientIP.lastIndexOf('.')); 32 | } 33 | // Anonymize IPv6 by removing the last 64 bits 34 | if (clientIP.includes(':')) { 35 | clientIP = clientIP.substring(0, clientIP.lastIndexOf(':')); 36 | } 37 | // Create hash with daily salt to create anon ID 38 | const id = crypto.createHash('sha256').update(`${ salt }${ clientIP }${ hostname }${ pathname }${ useragent }`).digest('hex'); 39 | // Connect to our database. 40 | const client = new Client({ 41 | secret: process.env.FAUNA_SECRET 42 | }); 43 | 44 | try { 45 | const query = fql`hits.createData({ 46 | usr_hash: ${id}, 47 | url: ${referer}, 48 | browser_name: ${clientBrowser.name}, 49 | browser_version: ${clientBrowser.version}, 50 | os_name: ${clientOS.name}, 51 | os_version: ${clientOS.version}, 52 | os_versionName: ${clientOS.versionName}, 53 | language: ${clientLanguage}, 54 | time: Time.now() 55 | })`; 56 | await client.query(query); 57 | } catch (error) { 58 | console.error(error); 59 | } 60 | 61 | } 62 | 63 | /* END */ 64 | 65 | // We respond with a transparent image 66 | return { 67 | statusCode: 200, 68 | body: 'R0lGODlhAQABAJAAAP8AAAAAACH5BAUQAAAALAAAAAABAAEAAAICBAEAOw==', 69 | headers: { 'content-type': 'image/gif' }, 70 | isBase64Encoded: true 71 | }; 72 | }; -------------------------------------------------------------------------------- /src/routes/stats/+page.ts: -------------------------------------------------------------------------------- 1 | 2 | import { hitsByBrowserCurrYear, hitsByLanguageCurrYear, hitsByDay, hitsByOSCurrYear, hitsByUrlCurrYear, hitsOverall, hitsTodayUnique } from "../../stores/dataStore"; 3 | import { getNameOfDay } from "$lib/helper"; 4 | import type { PageLoad } from './$types'; 5 | 6 | export const load: PageLoad = async ({ fetch }) => { 7 | 8 | const today = new Date(); 9 | // Current Date as string "2024-04-26" 10 | const currDate = today.toISOString().slice(0, 10); 11 | 12 | try { 13 | /* 14 | * Get overall hits 15 | */ 16 | const r_overall = await fetch(`/.netlify/functions/getHitsOverall`); 17 | const overall_json = await r_overall.json(); 18 | // Save response in store 19 | hitsOverall.set(overall_json.data); 20 | 21 | /* 22 | * Get hits of today (unique) 23 | */ 24 | const r_unique = await fetch(`/.netlify/functions/getHitsByDateUnique?date=${currDate}`); 25 | const unique_data = (await r_unique.json()).data; 26 | // Save response in store 27 | hitsTodayUnique.set(unique_data); 28 | 29 | /* 30 | * Get hits by day for the last 7 days 31 | */ 32 | // Create array with content [0, 1, 2, 3, 4, 5, 6] 33 | const daysToGet = [...Array(7).keys()]; 34 | const namesOfLastXDays: string[] = []; 35 | // Fetching hits for each day 36 | const hitsByDate = async (days: number) => { 37 | const date = new Date(); 38 | date.setDate(today.getDate() - days); 39 | namesOfLastXDays.push(getNameOfDay(date.getDay())); 40 | // Extract only the date part from the ISO string 41 | const dateString = date.toISOString().slice(0, 10); 42 | const response = await fetch(`/.netlify/functions/getHitsByDate?date=${dateString}`); 43 | return await response.json(); 44 | } 45 | 46 | // Await promises fetching hits for each day 47 | const promises = daysToGet.map(hitsByDate); 48 | const r_hitsByDay = await Promise.all(promises); 49 | const object = namesOfLastXDays.map((day: string, i: number) => { 50 | return { 51 | day: day, 52 | hits: r_hitsByDay[i].data 53 | } 54 | }); 55 | // Save hits in store 56 | hitsByDay.set(object); 57 | 58 | /* 59 | * Get hits by browser for current year 60 | */ 61 | const r_browserByYear = await fetch(`/.netlify/functions/getHitsByBrowserByYear?date=${currDate}`); 62 | const browserByYear_data = (await r_browserByYear.json()).data; 63 | // Save array of objects in store 64 | hitsByBrowserCurrYear.set(browserByYear_data); 65 | 66 | /* 67 | * Get hits by language for current year 68 | */ 69 | const r_languageByYear = await fetch(`/.netlify/functions/getHitsByLanguageByYear?date=${currDate}`); 70 | const languageByYear_data = (await r_languageByYear.json()).data; 71 | // Save array of objects in store 72 | hitsByLanguageCurrYear.set(languageByYear_data); 73 | 74 | /* 75 | * Get hits by os for current year 76 | */ 77 | const r_osByYear = await fetch(`/.netlify/functions/getHitsByOSByYear?date=${currDate}`); 78 | const osByYear_data = (await r_osByYear.json()).data; 79 | // Save array of objects in store 80 | hitsByOSCurrYear.set(osByYear_data); 81 | 82 | /* 83 | * Get hits by URL for current year 84 | */ 85 | const r_urlByYear = await fetch(`/.netlify/functions/getHitsByUrlByYear?date=${currDate}`); 86 | const urlByYear_data = (await r_urlByYear.json()).data; 87 | // Save array of objects in store 88 | hitsByUrlCurrYear.set(urlByYear_data); 89 | 90 | } catch (err) { 91 | console.error(err); 92 | } 93 | }; -------------------------------------------------------------------------------- /example_tracking/functions/utils/languagecodes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | aa: 'Afar', 3 | ab: 'Abkhazian', 4 | ae: 'Avestan', 5 | af: 'Afrikaans', 6 | ak: 'Akan', 7 | am: 'Amharic', 8 | an: 'Aragonese', 9 | ar: 'Arabic', 10 | as: 'Assamese', 11 | av: 'Avaric', 12 | ay: 'Aymara', 13 | az: 'Azerbaijani', 14 | ba: 'Bashkir', 15 | be: 'Belarusian', 16 | bg: 'Bulgarian', 17 | bh: 'Bihari languages', 18 | bi: 'Bislama', 19 | bm: 'Bambara', 20 | bn: 'Bengali', 21 | bo: 'Tibetan', 22 | br: 'Breton', 23 | bs: 'Bosnian', 24 | ca: 'Catalan / Valencian', 25 | ce: 'Chechen', 26 | ch: 'Chamorro', 27 | co: 'Corsican', 28 | cr: 'Cree', 29 | cs: 'Czech', 30 | cu: 'Church Slavic / Old Slavonic / Church Slavonic / Old Bulgarian / Old Church Slavonic', 31 | cv: 'Chuvash', 32 | cy: 'Welsh', 33 | da: 'Danish', 34 | de: 'German', 35 | dv: 'Divehi / Dhivehi / Maldivian', 36 | dz: 'Dzongkha', 37 | ee: 'Ewe', 38 | el: 'Greek', 39 | en: 'English', 40 | eo: 'Esperanto', 41 | es: 'Spanish / Castilian', 42 | et: 'Estonian', 43 | eu: 'Basque', 44 | fa: 'Persian', 45 | ff: 'Fulah', 46 | fi: 'Finnish', 47 | fj: 'Fijian', 48 | fo: 'Faroese', 49 | fr: 'French', 50 | fy: 'Western Frisian', 51 | ga: 'Irish', 52 | gd: 'Gaelic / Scottish Gaelic', 53 | gl: 'Galician', 54 | gn: 'Guarani', 55 | gu: 'Gujarati', 56 | gv: 'Manx', 57 | ha: 'Hausa', 58 | he: 'Hebrew', 59 | hi: 'Hindi', 60 | ho: 'Hiri Motu', 61 | hr: 'Croatian', 62 | ht: 'Haitian / Haitian Creole', 63 | hu: 'Hungarian', 64 | hy: 'Armenian', 65 | hz: 'Herero', 66 | ia: 'Interlingua', 67 | id: 'Indonesian', 68 | ie: 'Interlingue / Occidental', 69 | ig: 'Igbo', 70 | ii: 'Sichuan Yi / Nuosu', 71 | ik: 'Inupiaq', 72 | io: 'Ido', 73 | is: 'Icelandic', 74 | it: 'Italian', 75 | iu: 'Inuktitut', 76 | ja: 'Japanese', 77 | jv: 'Javanese', 78 | ka: 'Georgian', 79 | kg: 'Kongo', 80 | ki: 'Kikuyu / Gikuyu', 81 | kj: 'Kuanyama / Kwanyama', 82 | kk: 'Kazakh', 83 | kl: 'Kalaallisut / Greenlandic', 84 | km: 'Central Khmer', 85 | kn: 'Kannada', 86 | ko: 'Korean', 87 | kr: 'Kanuri', 88 | ks: 'Kashmiri', 89 | ku: 'Kurdish', 90 | kv: 'Komi', 91 | kw: 'Cornish', 92 | ky: 'Kirghiz / Kyrgyz', 93 | la: 'Latin', 94 | lb: 'Luxembourgish / Letzeburgesch', 95 | lg: 'Ganda', 96 | li: 'Limburgan / Limburger / Limburgish', 97 | ln: 'Lingala', 98 | lo: 'Lao', 99 | lt: 'Lithuanian', 100 | lu: 'Luba-Katanga', 101 | lv: 'Latvian', 102 | mg: 'Malagasy', 103 | mh: 'Marshallese', 104 | mi: 'Maori', 105 | mk: 'Macedonian', 106 | ml: 'Malayalam', 107 | mn: 'Mongolian', 108 | mr: 'Marathi', 109 | ms: 'Malay', 110 | mt: 'Maltese', 111 | my: 'Burmese', 112 | na: 'Nauru', 113 | nb: 'Bokmål, Norwegian / Norwegian Bokmål', 114 | nd: 'Ndebele, North / North Ndebele', 115 | ne: 'Nepali', 116 | ng: 'Ndonga', 117 | nl: 'Dutch / Flemish', 118 | nn: 'Norwegian Nynorsk / Nynorsk, Norwegian', 119 | no: 'Norwegian', 120 | nr: 'Ndebele, South / South Ndebele', 121 | nv: 'Navajo / Navaho', 122 | ny: 'Chichewa / Chewa / Nyanja', 123 | oc: 'Occitan (post 1500) / Provençal', 124 | oj: 'Ojibwa', 125 | om: 'Oromo', 126 | or: 'Oriya', 127 | os: 'Ossetian / Ossetic', 128 | pa: 'Panjabi / Punjabi', 129 | pi: 'Pali', 130 | pl: 'Polish', 131 | ps: 'Pushto / Pashto', 132 | pt: 'Portuguese', 133 | qu: 'Quechua', 134 | rm: 'Romansh', 135 | rn: 'Rundi', 136 | ro: 'Romanian / Moldavian / Moldovan', 137 | ru: 'Russian', 138 | rw: 'Kinyarwanda', 139 | sa: 'Sanskrit', 140 | sc: 'Sardinian', 141 | sd: 'Sindhi', 142 | se: 'Northern Sami', 143 | sg: 'Sango', 144 | si: 'Sinhala / Sinhalese', 145 | sk: 'Slovak', 146 | sl: 'Slovenian', 147 | sm: 'Samoan', 148 | sn: 'Shona', 149 | so: 'Somali', 150 | sq: 'Albanian', 151 | sr: 'Serbian', 152 | ss: 'Swati', 153 | st: 'Sotho, Southern', 154 | su: 'Sundanese', 155 | sv: 'Swedish', 156 | sw: 'Swahili', 157 | ta: 'Tamil', 158 | te: 'Telugu', 159 | tg: 'Tajik', 160 | th: 'Thai', 161 | ti: 'Tigrinya', 162 | tk: 'Turkmen', 163 | tl: 'Tagalog', 164 | tn: 'Tswana', 165 | to: 'Tonga', 166 | tr: 'Turkish', 167 | ts: 'Tsonga', 168 | tt: 'Tatar', 169 | tw: 'Twi', 170 | ty: 'Tahitian', 171 | ug: 'Uighur / Uyghur', 172 | uk: 'Ukrainian', 173 | ur: 'Urdu', 174 | uz: 'Uzbek', 175 | ve: 'Venda', 176 | vi: 'Vietnamese', 177 | vo: 'Volapük', 178 | wa: 'Walloon', 179 | wo: 'Wolof', 180 | xh: 'Xhosa', 181 | yi: 'Yiddish', 182 | yo: 'Yoruba', 183 | za: 'Zhuang / Chuang', 184 | zh: 'Chinese', 185 | zu: 'Zulu' 186 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > DEPRECATTION WARNING: Fauna is shutting down (May 30, 2025) and NETLIFY has declared its identity service as deprecated. Therefor, this project is abandoned and I encourage you to switch to one of the (freely) available analytics solutions out there. 3 | 4 | > [!TIP] 5 | > Check out [Vidu-Mini](https://github.com/pa-nic/vidu-mini). A minimal version of Vidu, making use of Netlify Blobs as data store. 6 | 7 | 8 |

9 | 10 |

Vidu - Minimal (jamstack) web analytics

11 | 12 |

13 | 14 | ## About 15 | 16 | This project was started for trying/learning the following tools and still end up with something useful (at least to me): 17 | 18 | - [Svelte](https://svelte.dev) javascript-framework (SvelteKit) 19 | - [Netlify](https://netlify.com) static hosting 20 | - [Netlify Identity](https://docs.netlify.com/visitor-access/identity/) for user authentication with [GoTrueJS](https://github.com/netlify/gotrue) 21 | - [Netlify Functions](https://docs.netlify.com/functions/overview/) AWS lambda functions for API calls 22 | - [Fauna](https://fauna.com) as transactional database 23 | - [GitHub Actions](https://docs.github.com/en/actions) to execute build hook of monitored web page to refresh encryption salt every day at midnight 24 | - [Netlify Build Plugins](https://docs.netlify.com/configure-builds/build-plugins/) to retain encryption *salt* on normal builds and refresh it if build web hook is executed by GitHub action 25 | - [Tailwind CSS](https://tailwindcss.com) Next generation Tailwind CSS compiler 26 | 27 | ### Functionality 28 | 29 | *Vidu* [see - /Esperanto/] consists of 30 | 31 | - A [Netlify Function](https://docs.netlify.com/functions/overview/) that is included as "tracker" in your web pages to collect (anonymized) user data and sending these to a [Fauna](https://fauna.com) database. 32 | - A [SvelteKit](https://svelte.dev) web app which displays all the data in a simple yet beautiful dashboard. 33 | 34 | ### Disclaimer 35 | 36 | This project was created for fun and educational purposes. 37 | 38 | *Vidu*, in general, has no limitation on processing page hits. But keep in mind that by using it for highly frequented web pages you will most likely exceed the free plans of Netlify and Fauna. 39 | 40 | Fork it. Extend it. It's "[unlicensed](./LICENSE)". 41 | 42 | ## Setup 43 | 44 | I assume you already have a [Netlify](https://netlify.com) account. 45 | 46 | ### Setup Fauna DB and Vidu 47 | 48 | 1. **Create database** 49 | 50 | If you don't have a Fauna account yet, sign up [here](https://dashboard.fauna.com/accounts/login). 51 | Create a new database. 52 | 53 | 2. **Create a database key** 54 | 55 | The *FAUNA_SECRET* for your database can be created in the Fauna dashboard by selecting your just created databse and browsing to *Keys*. 56 | 57 | Select *Create Key*. Fill in a name, select role *server*, press Save, and you will receive a new key. Make sure to copy it since you will only receive this key once. 58 | 59 | 3. **Create a collection** 60 | 61 | Create a new collection under your database name with the name *hits* (you can keep the default settings). 62 | Select the collection and to the *Schema* section and over-write its content with the following schema. Save. 63 | 64 | ``` 65 | collection hits { 66 | history_days 0 67 | ttl_days 2555 68 | compute date:Date = ( 69 | doc => Date(doc.time.toString().slice(0, 10)) 70 | ) 71 | compute os = ( 72 | doc => { 73 | if(doc.os_name != null) { 74 | if(doc.os_versionName != null) { 75 | doc.os_name + " " + doc.os_versionName 76 | } else (doc.os_name) 77 | } else("unknown") 78 | } 79 | ) 80 | compute year:Number = ( 81 | doc => doc.time.year 82 | ) 83 | compute month:Number = ( 84 | doc => doc.time.month 85 | ) 86 | index byDate { 87 | terms [.date] 88 | values [ .date ] 89 | } 90 | index byYear { 91 | terms [.year] 92 | values [ .date ] 93 | } 94 | index byYearByMonth { 95 | terms [.year, .month] 96 | values [ .date ] 97 | } 98 | } 99 | ``` 100 | 101 | 4. **Deploy repository to Netlify** 102 | 103 | With your server key ready, you can easily clone this repo and deploy this app in a few seconds by clicking the deploy button. Fill out the form and enter your *FAUNA_SECRET*. 104 | 105 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/pa-nic/vidu) 106 | 107 | 5. **Enable Identity** 108 | 109 | Enable Netlify Identity feature on your newly deployed Netlify site. Otherwise adding users and logins wont work. 110 | 111 | 112 | 6. **Configure emails sent during user registration and other actions** 113 | 114 | Under *Site Settings* go to *Identity* - *Emails* and configure the templates as follows: 115 | 116 | *Invitation template* **Path:** /email_templates/invitation.html
117 | *Recovery template* **Path:** /email_templates/recover.html 118 | 119 | 7. **Set registration to invite only** 120 | 121 | Under *Site Settings* go to *Identity* - *Overview* set *Registration preferences* to *Invite only*. You can then go to the *Identity* tab and invite/add your first user which should receive a confirmation email. 122 | 123 | ### Setup Tracking 124 | 125 | For how to implement the tracking in your web page take a look at the [example](./example_tracking). Further details below. 126 | 127 | **Check** your *FAUNA_SECRET* is configured correctly as an environment variable for this Netlify web page! 128 | 129 | #### Tracking Code 130 | 131 | The tracking code calls a Netlify function which pushes the tracking data to your fauna database and returns a bas64 encoded transparent image. 132 | 133 | ``` 134 | 141 | ``` 142 | 143 | #### Netlify Function Node Dependencies 144 | 145 | See [example: package.json](./example_tracking/package.json). 146 | 147 | ### Gathered Data 148 | 149 | - URL of visited page 150 | - Browser 151 | - Browser version 152 | - OS 153 | - OS version number 154 | - OS version name 155 | - Client language 156 | - Timestamp 157 | - id 158 | 159 | The *id* is an anonymized user string composed as follows: 160 | 161 | ### Anonymized ID 162 | 163 | To compose the *id* the last byte of the tracked IP address is removed (the last 64bits if IPv6) and hashed (sha256) together with the above listed and *salted* data. As the URL is also included in the hash, you wont be able to track the browsing behavior of individuals across your page. See [example: counter.js](./example_tracking/functions/counter.js) for details. 164 | 165 | The 16 bytes random *salt* is created **once** during page build by making use of a Netlify plugin([Example: netlify-plugin-handle-mysalt](./example_tracking/plugins/netlify-plugin-handle-mysalt)). If a *salt* was already present it will be restored from cache during a new page build. 166 | 167 | Nevertheless, the *salt* is always kept separated and never stored in the database. 168 | 169 | ### Further Improve Anonymization 170 | 171 | To further improve the anonymization of the gathered data you can setup a github workflow([Example: workflow](./example_tracking/.github/workflows/main.yml)) running a build hook to rebuild your web page every day at midnight (UTC). The above mentioned Netlify plugin will recognize that the page build was triggered by your build hook and in **this** case a **new** *salt* will be created during page build. 172 | 173 | - Add a build hook under *Site Settings* - *Build & deploy* - *Build hooks*. 174 | - Name it *refreshsalt* 175 | - Edit the [Example: workflow](./example_tracking/.github/workflows/main.yml) by adding the created url to it: 176 | 177 | ``` 178 | run: curl -X POST -d 'refreshsalt' &trigger_title=Refreshing+salt+by+github+webhook 179 | ``` 180 | 181 |

And that's just about it!

182 | -------------------------------------------------------------------------------- /src/routes/stats/+page.svelte: -------------------------------------------------------------------------------- 1 | 303 | 304 | 314 | 315 |
316 | 317 | 322 | 327 | 328 | o.hits).reduce((sum, n) => sum + n, 0)} 331 | class="p-4 rounded-lg bg-gray-700 text-white" 332 | /> 333 | 334 | 339 | 340 |
341 |
342 |
343 |
344 |
345 |

Hits history

346 |
347 | 354 | 361 | 368 |
369 |
370 | 371 |
372 |
373 |
374 | {#if viewHitsHistoryByDay} 375 | Day 376 | {:else if viewHitsHistoryByMonth} 377 | Month 378 | {:else if viewHitsHistoryByYear} 379 | Year 380 | {/if} 381 |
382 |
383 | Hits 384 |
385 |
386 | {#if $fetchingHitsByDate} 387 |
388 | 389 |
390 | {:else} 391 | {#if viewHitsHistoryByDay} 392 | {#each $hitsByDay as {day, hits}} 393 | 398 | {/each} 399 | {:else if viewHitsHistoryByMonth} 400 | {#each $hitsByMonth as {month, hits}} 401 | 406 | {/each} 407 | {:else if viewHitsHistoryByYear} 408 | {#each $hitsByYear as {year, hits}} 409 | 414 | {/each} 415 | {/if} 416 | {/if} 417 |
418 |
419 | 420 |
421 |
422 |

Top 10 pages

423 |
424 | 431 | 438 |
439 |
440 | 441 |
442 |
443 |
444 | Page 445 |
446 |
447 | Hits 448 |
449 |
450 | {#if $fetchingHitsByUrl} 451 |
452 | 453 |
454 | {:else} 455 | {#if viewPageHitsThisYear} 456 | {#each $hitsByUrlCurrYear as { url, hits }} 457 | 461 | {/each} 462 | {:else if viewPageHitsOverall} 463 | {#each $hitsByUrlOverall as { url, hits }} 464 | 468 | {/each} 469 | {/if} 470 | {/if} 471 |
472 | 473 |
474 | 475 |
476 |
477 |

Top 10 browsers

478 |
479 | 486 | 493 |
494 |
495 | 496 |
497 |
498 |
499 | Browser 500 |
501 |
502 | Hits 503 |
504 |
505 | {#if $fetchingHitsByBrowser} 506 |
507 | 508 |
509 | {:else} 510 | {#if viewBrowserHitsThisYear} 511 | {#each $hitsByBrowserCurrYear as {browser, hits}} 512 | 517 | {/each} 518 | {:else if viewBrowserHitsOverall} 519 | {#each $hitsByBrowserOverall as {browser, hits}} 520 | 525 | {/each} 526 | {/if} 527 | {/if} 528 |
529 | 530 |
531 | 532 |
533 | 534 |
535 |
536 |
537 |

Top 10 OS

538 |
539 | 546 | 553 |
554 |
555 | 556 |
557 |
558 |
559 | OS 560 |
561 |
562 | Hits 563 |
564 |
565 | {#if $fetchingHitsByOS} 566 |
567 | 568 |
569 | {:else} 570 | {#if viewOSHitsThisYear} 571 | {#each $hitsByOSCurrYear as {os, hits}} 572 | 577 | {/each} 578 | {:else if viewOSHitsOverall} 579 | {#each $hitsByOSOverall as {os, hits}} 580 | 585 | {/each} 586 | {/if} 587 | {/if} 588 |
589 | 590 |
591 | 592 |
593 |
594 |

Top 10 languages

595 |
596 | 603 | 610 |
611 |
612 | 613 |
614 |
615 |
616 | Language 617 |
618 |
619 | Hits 620 |
621 |
622 | {#if $fetchingHitsByLanguage} 623 |
624 | 625 |
626 | {:else} 627 | {#if viewLanguageHitsThisYear} 628 | {#each $hitsByLanguageCurrYear as {language, hits}} 629 | 634 | {/each} 635 | {:else if viewLanguageHitsOverall} 636 | {#each $hitsByLanguageOverall as {language, hits}} 637 | 642 | {/each} 643 | {/if} 644 | {/if} 645 |
646 | 647 |
648 |
649 |
650 | --------------------------------------------------------------------------------