├── .cargo
└── config.toml
├── .github
├── .gitattributes
├── CONTRIBUTING.md
└── workflows
│ ├── audit.yaml.disable
│ ├── container.yaml
│ ├── release.yaml
│ ├── test-web.yaml
│ └── test.yaml
├── .gitignore
├── .rustfmt.toml
├── CHANGELOG.md
├── Cargo.lock
├── Cargo.toml
├── LICENSE.md
├── NOTICE.md
├── README.md
├── about.toml
├── audit.toml
├── biome.json
├── data
├── config.example.toml
├── countries.txt
├── geo.json
├── images
│ ├── liwan-desktop-dark.png
│ ├── liwan-desktop-full-dark.png
│ ├── liwan-desktop-full.png
│ └── liwan-desktop.png
├── licenses-cargo.json
├── licenses-npm.json
├── referrer_icons.txt
├── referrers.txt
├── spammers.txt
└── ua_regexes.yaml
├── scripts
├── Dockerfile
├── build-licenses.sh
├── build-tracker.sh
├── deploy-demo.sh
└── screenshot
│ ├── .gitignore
│ ├── bun.lockb
│ ├── index.ts
│ └── package.json
├── src
├── app
│ ├── core.rs
│ ├── core
│ │ ├── entities.rs
│ │ ├── events.rs
│ │ ├── geoip.rs
│ │ ├── onboarding.rs
│ │ ├── projects.rs
│ │ ├── reports.rs
│ │ ├── reports_cached.rs
│ │ ├── sessions.rs
│ │ └── users.rs
│ ├── db.rs
│ ├── mod.rs
│ └── models.rs
├── cli.rs
├── config.rs
├── lib.rs
├── main.rs
├── migrations
│ ├── app
│ │ └── V1__initial.sql
│ └── events
│ │ ├── V1__initial.sql
│ │ ├── V2__remove_indexes.sql
│ │ ├── V3__utm_params.sql
│ │ └── V4__session_intervals.sql
├── utils
│ ├── duckdb.rs
│ ├── geo.rs
│ ├── hash.rs
│ ├── mod.rs
│ ├── referrer.rs
│ ├── refinery_duckdb.rs
│ ├── refinery_sqlite.rs
│ ├── seed.rs
│ ├── useragent.rs
│ └── validate.rs
└── web
│ ├── mod.rs
│ ├── routes
│ ├── admin.rs
│ ├── auth.rs
│ ├── dashboard.rs
│ ├── event.rs
│ └── mod.rs
│ ├── session.rs
│ └── webext.rs
├── tests
├── auth.rs
├── common
│ └── mod.rs
├── dashboard.rs
└── event.rs
├── tracker
├── LICENSE.md
├── README.md
├── package.json
├── script.d.ts
├── script.min.js
└── script.ts
└── web
├── .gitignore
├── astro.config.ts
├── bun.lock
├── package.json
├── public
├── favicon.ico
└── favicon.svg
├── src
├── api
│ ├── client.ts
│ ├── constants.ts
│ ├── dashboard.ts
│ ├── hooks.ts
│ ├── index.ts
│ ├── query.ts
│ ├── ranges.test.ts
│ ├── ranges.ts
│ └── types.ts
├── components
│ ├── ThemeSwitcher.astro
│ ├── card.module.css
│ ├── card.tsx
│ ├── daterange
│ │ ├── daterange.module.css
│ │ └── index.tsx
│ ├── dialog.module.css
│ ├── dialog.tsx
│ ├── dimensions
│ │ ├── dimensions.module.css
│ │ ├── index.tsx
│ │ └── modal.tsx
│ ├── graph
│ │ ├── axis.ts
│ │ ├── graph.module.css
│ │ ├── graph.tsx
│ │ └── index.tsx
│ ├── icons.module.css
│ ├── icons.tsx
│ ├── project.module.css
│ ├── project.tsx
│ ├── project
│ │ ├── filter.module.css
│ │ ├── filter.tsx
│ │ ├── metric.module.css
│ │ ├── metric.tsx
│ │ ├── project.module.css
│ │ ├── project.tsx
│ │ ├── range.module.css
│ │ └── range.tsx
│ ├── projects.module.css
│ ├── projects.tsx
│ ├── settings
│ │ ├── dialogs.module.css
│ │ ├── dialogs.tsx
│ │ ├── me.module.css
│ │ ├── me.tsx
│ │ ├── tables.module.css
│ │ └── tables.tsx
│ ├── table.module.css
│ ├── table.tsx
│ ├── tags.module.css
│ ├── tags.tsx
│ ├── toast.module.css
│ ├── toast.ts
│ ├── userInfo.module.css
│ ├── userInfo.tsx
│ └── worldmap
│ │ ├── index.tsx
│ │ └── map.module.css
├── env.d.ts
├── global.css
├── hooks
│ └── persist.ts
├── layouts
│ ├── Base.astro
│ ├── Layout.astro
│ └── Settings.astro
├── pages
│ ├── attributions.astro
│ ├── index.astro
│ ├── login.astro
│ ├── p
│ │ └── [...project].astro
│ ├── settings
│ │ ├── entities.astro
│ │ ├── me.astro
│ │ ├── projects.astro
│ │ └── users.astro
│ └── setup.astro
├── utils.test.ts
└── utils.ts
└── tsconfig.json
/.cargo/config.toml:
--------------------------------------------------------------------------------
1 | [build]
2 |
3 | [target.x86_64-unknown-linux-musl]
4 | rustflags=["-C", "target_cpu=x86-64-v2"]
5 |
--------------------------------------------------------------------------------
/.github/.gitattributes:
--------------------------------------------------------------------------------
1 | **/bun.lock linguist-generated=true
2 |
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Licensing
2 |
3 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in Liwan by you, as defined in the Apache-2.0 license, shall be licensed under the terms of the [Apache License, Version 2.0](https://opensource.org/license/apache-2-0) without any additional terms or conditions.
4 |
--------------------------------------------------------------------------------
/.github/workflows/audit.yaml.disable:
--------------------------------------------------------------------------------
1 | name: "Audit Dependencies"
2 | on:
3 | push:
4 | paths:
5 | # Run if workflow changes
6 | - ".github/workflows/audit.yml"
7 | # Run on changed dependencies
8 | - "**/Cargo.toml"
9 | - "**/Cargo.lock"
10 | # Run if the configuration file changes
11 | - "**/audit.toml"
12 | # Rerun periodicly to pick up new advisories
13 | schedule:
14 | - cron: "0 0 * * *"
15 | # Run manually
16 | workflow_dispatch:
17 |
18 | jobs:
19 | audit:
20 | runs-on: ubuntu-latest
21 | permissions:
22 | contents: read
23 | issues: write
24 | steps:
25 | - uses: actions/checkout@v4
26 | - uses: actions-rust-lang/audit@v1
27 | name: Audit Rust Dependencies
28 |
--------------------------------------------------------------------------------
/.github/workflows/container.yaml:
--------------------------------------------------------------------------------
1 | name: "Build & Publish Container Image"
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | tag:
7 | type: string
8 | required: true
9 | workflow_dispatch:
10 | inputs:
11 | tag:
12 | description: "Tag (e.g. liwan-v0.1.0)"
13 | required: true
14 | type: string
15 |
16 | jobs:
17 | docker-build:
18 | permissions:
19 | contents: read
20 | packages: write
21 |
22 | runs-on: ubuntu-latest
23 | steps:
24 | - uses: actions/checkout@v4
25 | - name: Setup Docker Buildx
26 | uses: docker/setup-buildx-action@v3
27 | - name: Extract Semver
28 | id: semver
29 | env:
30 | INPUT_TAG: "${{ inputs.tag }}"
31 | run: |
32 | SEMVER_VERSION=$(echo "$INPUT_TAG" | sed -E 's/liwan-v//')
33 | echo "SEMVER_VERSION=${SEMVER_VERSION}" >> "$GITHUB_OUTPUT"
34 | - name: Setup Docker Metadata
35 | uses: docker/metadata-action@v5
36 | id: meta
37 | with:
38 | images: ghcr.io/${{ github.actor }}/liwan
39 | tags: |
40 | type=semver,pattern={{version}},value=${{ steps.semver.outputs.SEMVER_VERSION }}
41 | type=semver,pattern={{major}}.{{minor}},value=${{ steps.semver.outputs.SEMVER_VERSION }}
42 | type=semver,pattern={{major}},value=${{ steps.semver.outputs.SEMVER_VERSION }}
43 | type=raw,edge
44 | - name: Login to GitHub Container Registry
45 | uses: docker/login-action@v3
46 | with:
47 | registry: ghcr.io
48 | username: ${{ github.actor }}
49 | password: ${{ secrets.GITHUB_TOKEN }}
50 | - name: Build and push Docker images
51 | uses: docker/build-push-action@v6
52 | with:
53 | context: .
54 | file: ./scripts/Dockerfile
55 | push: true
56 | tags: ${{ steps.meta.outputs.tags }}
57 | labels: ${{ steps.meta.outputs.labels }}
58 | platforms: |
59 | linux/amd64
60 | linux/arm64
61 | build-args: |
62 | TAR_URL_AMD64=https://github.com/explodingcamera/liwan/releases/download/${{ inputs.tag }}/liwan-x86_64-unknown-linux-musl.tar.gz
63 | TAR_URL_ARM64=https://github.com/explodingcamera/liwan/releases/download/${{ inputs.tag }}/liwan-aarch64-unknown-linux-musl.tar.gz
64 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - liwan-v[0-9]+.*
7 |
8 | jobs:
9 | create-release:
10 | permissions:
11 | contents: write
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 | with:
16 | persist-credentials: false
17 | - uses: taiki-e/create-gh-release-action@v1.9.1
18 | with:
19 | changelog: CHANGELOG.md
20 | allow-missing-changelog: true
21 | draft: false # has to be public to have the right url's for the docker image
22 | prefix: liwan
23 | token: ${{ secrets.GITHUB_TOKEN }}
24 |
25 | build-web:
26 | runs-on: ubuntu-latest
27 | steps:
28 | - uses: actions/checkout@v4
29 | with:
30 | persist-credentials: false
31 | - uses: oven-sh/setup-bun@v2
32 | with:
33 | bun-version: latest
34 | - name: Build web project
35 | run: |
36 | bun install
37 | bun run build
38 | working-directory: ./web
39 | - name: Upload web assets
40 | uses: actions/upload-artifact@v4
41 | with:
42 | name: web-dist
43 | path: ./web/dist
44 |
45 | upload-assets:
46 | permissions:
47 | contents: write
48 | needs: [create-release, build-web]
49 | strategy:
50 | matrix:
51 | include:
52 | - target: x86_64-unknown-linux-musl
53 | os: ubuntu-latest
54 | build-tool: cargo-zigbuild
55 | - target: aarch64-unknown-linux-musl
56 | os: ubuntu-latest
57 | build-tool: cargo-zigbuild
58 | - target: aarch64-apple-darwin
59 | os: macos-latest
60 | build-tool: cargo
61 |
62 | runs-on: ${{ matrix.os }}
63 | steps:
64 | - uses: actions/checkout@v4
65 | with:
66 | persist-credentials: false
67 | - uses: actions/download-artifact@v4
68 | with:
69 | name: web-dist
70 | path: ./web/dist
71 | - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
72 | - uses: taiki-e/upload-rust-binary-action@v1.26.0
73 | with:
74 | bin: liwan
75 | target: ${{ matrix.target }}
76 | build-tool: ${{ matrix.build-tool }}
77 | token: ${{ secrets.GITHUB_TOKEN }}
78 |
79 | publish-container:
80 | permissions:
81 | packages: write
82 | contents: read
83 | needs: [create-release, upload-assets]
84 | uses: explodingcamera/liwan/.github/workflows/container.yaml@main
85 | with:
86 | tag: ${{ github.ref_name }}
87 |
--------------------------------------------------------------------------------
/.github/workflows/test-web.yaml:
--------------------------------------------------------------------------------
1 | name: Run tests (web)
2 |
3 | on:
4 | pull_request:
5 | paths:
6 | - "**/*.ts"
7 | - "**/*.tsx"
8 | - "web/bun.lock"
9 | push:
10 | paths:
11 | - "**/*.ts"
12 | - "**/*.tsx"
13 | - "web/bun.lock"
14 | workflow_dispatch:
15 |
16 | jobs:
17 | test:
18 | runs-on: ubuntu-latest
19 | steps:
20 | - uses: actions/checkout@v4
21 | with:
22 | persist-credentials: false
23 | - uses: oven-sh/setup-bun@v2
24 | with:
25 | bun-version: latest
26 | - name: Build web project
27 | run: |
28 | bun install
29 | bun run build
30 | working-directory: ./web
31 | - name: Run tests
32 | run: bun test
33 | working-directory: ./web
34 | - name: Run typecheck
35 | run: bun typecheck
36 | working-directory: ./web
37 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on:
4 | pull_request:
5 | paths:
6 | - "**/*.rs"
7 | - "Cargo.lock"
8 | push:
9 | paths:
10 | - "**/*.rs"
11 | - "Cargo.lock"
12 | workflow_dispatch:
13 |
14 | jobs:
15 | test:
16 | strategy:
17 | matrix:
18 | os:
19 | - ubuntu-latest
20 | - macos-latest
21 | name: Run tests on ${{ matrix.os }}
22 | runs-on: ${{ matrix.os }}
23 | steps:
24 | - uses: actions/checkout@v4
25 | with:
26 | persist-credentials: false
27 | - uses: actions-rust-lang/setup-rust-toolchain@v1
28 | - uses: Swatinem/rust-cache@v2
29 | - run: mkdir ./web/dist
30 | - name: Run tests
31 | run: cargo test --all --all-features
32 | - name: Run clippy
33 | run: cargo clippy --all-features -- -W warnings
34 | - name: Run fmt
35 | run: cargo fmt --all -- --check
36 | continue-on-error: true
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | liwan.config.toml
3 | /liwan-data
4 |
--------------------------------------------------------------------------------
/.rustfmt.toml:
--------------------------------------------------------------------------------
1 | max_width=120
2 | use_small_heuristics="Max"
3 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 |
18 |
19 |
20 |
21 | ## [v1.2.0] - 2025-05-19
22 |
23 | - Liwan has been relicensed under the terms of the **Apache-2.0** license (this also applies to all previous versions)
24 | - Updated to the latest version of DuckDB (1.2)
25 | - Updated list of referrer spammers, user-agents and site icons
26 | - Ellipsis for long URLs in the UI
27 | - Overall performance improvements and memory usage optimizations
28 |
29 | ## [v1.1.1] - 2025-05-19
30 |
31 | - Force duckdb to always checkpoint the database after shutting down. This is required for upgrading to the latest version of duckdb in liwan 1.2, which has issues loading the old checkpoint files when using musl libc.
32 |
33 | ## [v1.1.0] - 2024-12-28
34 |
35 | - Improved query caching to prevent unnecessary database queries
36 | - Added Country Code to Google Referrer URLs
37 | - Improved Multi-User Support (Non-admin users can now be granted access to specific projects)
38 |
39 | ## [v1.0.0] - 2024-12-06
40 |
41 | ### 🚀 Features
42 |
43 | - **UTM parameters**: Added support for UTM parameters. You can filter and search by UTM source, medium, campaign, content, and term. ([#13](https://github.com/explodingcamera/liwan/pull/13))
44 | - **New Date Ranges**: Fully reworked date ranges. Data is more accurate and consistent now, and you can move to the next or previous time range. Also includes some new time ranges like `Week to Date` and `All Time`. You can now also select a custom date range to view your data. ([97cdfce](https://github.com/explodingcamera/liwan/commit/97cdfce509ed2fd2fd74b23c73726a5e01b7b288), [391c580](https://github.com/explodingcamera/liwan/commit/391c580c926e2b4ca250e08bbe725210774d99b2))
45 | - **UI Improvements**: A lot of small improvements to the UI for better polish and usability.
46 | - **New Metrics**: Added new metrics: `Bounce Rate`, `Average Time on Page` ([97cdfce](https://github.com/explodingcamera/liwan/commit/97cdfce509ed2fd2fd74b23c73726a5e01b7b288))
47 | - **Favicons can be disabled**: You can now disable fetching favicons from DuckDuckGo (`config.toml` setting: `disable_favicons`) ([2100bfe](https://github.com/explodingcamera/liwan/commit/2100bfe6ba868b59d2b383220f22b0dbf23a6712))
48 | - **New Graphs**: Graphs are now custom-built using d3 directly to improve performance and flexibility. ([eb1415d](https://github.com/explodingcamera/liwan/commit/eb1415d6bdf6d3be9509b0b4fa743b6f112b2c0a))
49 |
50 | ### 🐛 Bug Fixes
51 |
52 | - Fixed a potential panic when entities are not found in the database ([`31405a7`](https://github.com/explodingcamera/liwan/commit/31405a721dc5c5493098e211927281cca7816fec))
53 | - Fixed issues with the `Yesterday` Date Range ([`76278b57`](https://github.com/explodingcamera/liwan/commit/76278b579c5fe1557bf1c184542ed6ed2aba57cd))
54 | - Fixed issue with NaN values in the bounce rate metric ([eb1415d](https://github.com/explodingcamera/liwan/commit/eb1415d6bdf6d3be9509b0b4fa743b6f112b2c0a))
55 |
56 | ### Other
57 |
58 | - Removed Sessions and Average Views per Session metrics. They were not accurate and were removed to avoid confusion.
59 | - Added more tests & improved API performance ([`95d95d0`](https://github.com/explodingcamera/liwan/commit/95d95d0f4670d20a6fa4fc6a7f4b17e4b1854391))
60 |
61 | ## [v0.1.1] - 2024-09-24
62 |
63 | ### ⚡ Performance
64 |
65 | - **Database indexes**: Removed unnecessary indexes to improve performance and reduce disk usage ([`6191a72`](https://github.com/explodingcamera/liwan/commit/6191a72f08e8659237bc6c22139bde94432f66bb))
66 |
67 | ## [v0.1.0] - 2024-09-18
68 |
69 | This is the first full release of the Liwan! 🎉
70 | All essential features for web analytics are now available, including:
71 |
72 | - Live tracking of page views
73 | - Geolocation of visitors
74 | - Automatic GeoIP database updates
75 | - Basic user management
76 | - Filtering and searching
77 | - Multiple tracking dimensions: URL, referrer, browser, OS, device type, country, and city
78 | - Multiple metrics: page views, unique visitors, sessions, and average views per session
79 | - Multiple date ranges (custom date ranges are coming soon!)
80 | - Documentation and a simple setup guide at [liwan.dev](https://liwan.dev)
81 | - A simple and clean UI
82 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name="liwan"
3 | version="1.2.0"
4 | edition="2024"
5 | rust-version="1.87"
6 | repository="https://github.com/explodingcamera/liwan"
7 | license="Apache-2.0"
8 | publish=false
9 |
10 | [lib]
11 | path="src/lib.rs"
12 |
13 | [[bin]]
14 | name="liwan"
15 | path="src/main.rs"
16 |
17 | [dependencies]
18 | # async/concurrency
19 | tokio={version="1.44", default-features=false, features=["macros", "rt-multi-thread", "signal"]}
20 | tokio-util={version="0.7", features=["io"]}
21 | futures-lite="2.6"
22 | crossbeam-utils="0.8"
23 | crossbeam-channel="0.5"
24 | quick_cache="0.6"
25 |
26 | # encoding
27 | hex={version="0.4"}
28 | bs58="0.5"
29 | serde={version="1.0", features=["derive"]}
30 | serde_json={version="1.0"}
31 | md-5="0.10"
32 | async-compression={version="0.4", default-features=false, features=["gzip", "tokio"]}
33 | tokio-tar={package="astral-tokio-tar", version="0.5"}
34 | sha3={version="0.10"}
35 | argon2={version="0.5"}
36 |
37 | # general
38 | argh={version="0.1"}
39 | eyre={version="0.6"}
40 | rand={version="0.9"}
41 | time={version="0.3"}
42 | colored={version="3.0"}
43 | figment={version="0.10", features=["toml", "env"]}
44 | tracing={version="0.1", default-features=false, features=["std"]}
45 | tracing-subscriber={version="0.3", features=["env-filter"]}
46 |
47 | # web
48 | poem={version="3.1", default-features=false, features=[
49 | "embed",
50 | "cookie",
51 | "compression",
52 | "tower-compat",
53 | ]}
54 | poem-openapi={version="5.1", default-features=false, features=["time"]}
55 | tower={version="0.5", default-features=false, features=["limit"]}
56 | uaparser="0.6"
57 | mime_guess={version="2.0"}
58 | rust-embed={version="8.7"}
59 | reqwest={version="0.12", default-features=false, features=[
60 | "json",
61 | "stream",
62 | "charset",
63 | "rustls-tls-webpki-roots-no-provider",
64 | ]}
65 | rustls={version="0.23", features=["aws_lc_rs"]}
66 |
67 | # database
68 | duckdb={git="https://github.com/explodingcamera-contrib/duckdb-rs", features=[
69 | "buildtime_bindgen",
70 | "bundled",
71 | "time",
72 | "r2d2",
73 | ]}
74 | rusqlite={version="0.35", features=["bundled", "modern_sqlite", "time"]}
75 | r2d2={version="0.8"}
76 | r2d2_sqlite="0.28"
77 | refinery={version="0.8", default-features=false}
78 | refinery-core={version="0.8", default-features=false}
79 | maxminddb={version="0.26", optional=true}
80 | ahash="0.8"
81 |
82 | [target.'cfg(not(target_env = "msvc"))'.dependencies]
83 | tikv-jemallocator="0.6"
84 |
85 | [dev-dependencies]
86 | figment={version="*", features=["test"]}
87 | poem={version="*", features=["test"]}
88 | cookie={version="*", default-features=false}
89 |
90 | [features]
91 | default=["geoip"]
92 | geoip=["dep:maxminddb"]
93 | _enable_seeding=[]
94 |
95 | [profile.dev]
96 | opt-level=1
97 |
98 | [profile.release]
99 | lto="thin"
100 | panic="abort"
101 | strip=true
102 | opt-level=2
103 |
--------------------------------------------------------------------------------
/NOTICE.md:
--------------------------------------------------------------------------------
1 | - `data/ua_regexes.yaml` is based on data from [ua-parser/uap-core](https://github.com/ua-parser/uap-core/blob/master/regexes.yaml) (Copyright 2009 Google Inc. and available under the Apache License, Version 2.0)
2 | - `data/spammers.txt` is in the public domain (see [matomo-org/referrer-spam-list](https://github.com/matomo-org/referrer-spam-list))
3 | - `data/socials.txt` is based on [matomo-org/searchengine-and-social-list](https://github.com/matomo-org/searchengine-and-social-list) (available under the CC0 1.0 Universal Public Domain Dedication)
4 | - `data/geo.json` is based on data from [Natural Earth](https://naturalearthdata.com/) (which itself is in the public domain)
5 | - See [CONTRIBUTING](.github/CONTRIBUTING.md) for more information on licensing of contributions from external contributors.
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | liwan.dev - Easy & Privacy-First Web Analytics
7 |
8 |
9 |
10 | 
11 | 
12 | [](https://github.com/explodingcamera/liwan/pkgs/container/liwan)
13 |
14 |
15 |
16 |
17 |
18 |
19 |

20 |

21 |
22 |
23 | ## Features
24 |
25 | - **Quick setup**\
26 | Quickly get started with Liwan with a single, self-contained binary . No database or complex setup required. The tracking script is a single line of code that works with any website and less than 1KB in size.
27 | - **Privacy first**\
28 | Liwan respects your users’ privacy by default. No cookies, no cross-site tracking, no persistent identifiers. All data is stored on your server.
29 | - **Lightweight**\
30 | You can run Liwan on a cheap VPS, your old mac mini, or even a Raspberry Pi. Written in Rust and using tokio for async I/O, Liwan is fast and efficient.
31 | - **Open source**\
32 | Fully open source. You can change, extend, and contribute to the codebase.
33 | - **Accurate data**\
34 | Get accurate data about your website’s visitors, page views, referrers, and more. Liwan detects bots and crawlers and filters them out by default.
35 | - **Real-time analytics**\
36 | See your website’s traffic in real-time. Liwan updates the dashboard automatically as new visitors come in.
37 |
38 | ## License
39 |
40 | Unless otherwise noted, the code in this repository is available under the terms of the Apache-2.0 license. See [LICENSE](LICENSE.md) for more information.
41 |
--------------------------------------------------------------------------------
/about.toml:
--------------------------------------------------------------------------------
1 | workarounds=["ring", "rustls"]
2 | accepted=[
3 | "Apache-2.0",
4 | "MIT",
5 | "ISC",
6 | "MPL-2.0",
7 | "BSD-3-Clause",
8 | "Unicode-DFS-2016",
9 | "Unicode-3.0",
10 | "OpenSSL",
11 | "Zlib",
12 | "CDLA-Permissive-2.0",
13 | ]
14 | targets=["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl", "aarch64-apple-darwin"]
15 | ignore-build-dependencies=true
16 | ignore-dev-dependencies=true
17 | no-clearly-defined=true
18 |
--------------------------------------------------------------------------------
/audit.toml:
--------------------------------------------------------------------------------
1 | [advisories]
2 | ignore=[]
3 | informational_warnings=["unsound"]
4 | severity_threshold="low"
5 |
6 | [target]
7 | arch=["x86_64", "aarch64"]
8 | os=["linux", "windows", "apple"]
9 |
10 | [yanked]
11 | enabled=true
12 | update_index=true
13 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "formatter": {
4 | "indentStyle": "tab",
5 | "indentWidth": 2,
6 | "lineWidth": 120
7 | },
8 | "vcs": {
9 | "enabled": true,
10 | "useIgnoreFile": true,
11 | "clientKind": "git",
12 | "defaultBranch": "main"
13 | },
14 | "css": {
15 | "parser": { "cssModules": true },
16 | "formatter": {
17 | "indentStyle": "space"
18 | }
19 | },
20 | "linter": {
21 | "rules": {
22 | "suspicious": {
23 | "noArrayIndexKey": "off"
24 | },
25 | "a11y": {
26 | "useSemanticElements": "off"
27 | }
28 | }
29 | },
30 | "files": {
31 | "ignore": [
32 | "**/node_modules/*",
33 | "**/dist/*",
34 | "**/target/*",
35 | "**/.astro/*",
36 | "**/api/dashboard.ts",
37 | "tracker/script.min.js",
38 | "tracker/script.d.ts"
39 | ]
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/data/config.example.toml:
--------------------------------------------------------------------------------
1 | # The base URL of the Liwan instance
2 | base_url="http://localhost:9042"
3 |
4 | # The port to listen on (http)
5 | port=9042
6 |
7 | # # Folder to store the database in (Will be created if it doesn't exist)
8 | # # defaults to $HOME/.local/share/liwan/data on linux/macos
9 | # # defaults to ./liwan-data on other platforms
10 | # data_dir="./liwan-data"
11 |
12 | # GeoIp settings (Optional)
13 | [geoip]
14 | # # MaxMind account ID and license key
15 | # # Required to automatically download the database and keep it up to date
16 | # maxmind_account_id="MY_ACCOUNT_ID"
17 | # maxmind_license_key="MY_LICENSE_KEY"
18 | # maxmind_edition="GeoLite2-City"
19 |
20 | # # If you don't want to automatically download the database, you can specify a path to a local file
21 | # # Otherwise, the database will be downloaded automatically and stored in the data_dir
22 | # maxmind_db_path="./GeoLite2-City.mmdb"
23 |
24 | [duckdb]
25 | # See https://liwan.dev/guides/duckdb
26 | # threads=2
27 | # memory_limit=2G
28 |
--------------------------------------------------------------------------------
/data/countries.txt:
--------------------------------------------------------------------------------
1 | Afghanistan=AF
2 | Åland Islands=AX
3 | Albania=AL
4 | Algeria=DZ
5 | American Samoa=AS
6 | Andorra=AD
7 | Angola=AO
8 | Anguilla=AI
9 | Antarctica=AQ
10 | Antigua and Barbuda=AG
11 | Argentina=AR
12 | Armenia=AM
13 | Aruba=AW
14 | Australia=AU
15 | Austria=AT
16 | Azerbaijan=AZ
17 | Bahamas=BS
18 | Bahrain=BH
19 | Bangladesh=BD
20 | Barbados=BB
21 | Belarus=BY
22 | Belgium=BE
23 | Belize=BZ
24 | Benin=BJ
25 | Bermuda=BM
26 | Bhutan=BT
27 | Bolivia=BO
28 | Bonaire=BQ
29 | Bosnia and Herzegovina=BA
30 | Botswana=BW
31 | Bouvet Island=BV
32 | Brazil=BR
33 | British Indian Ocean Territory=IO
34 | Brunei Darussalam=BN
35 | Bulgaria=BG
36 | Burkina Faso=BF
37 | Burundi=BI
38 | Cabo Verde=CV
39 | Cambodia=KH
40 | Cameroon=CM
41 | Canada=CA
42 | Cayman Islands=KY
43 | Central African Republic=CF
44 | Chad=TD
45 | Chile=CL
46 | China=CN
47 | Christmas Island=CX
48 | Cocos Islands=CC
49 | Colombia=CO
50 | Comoros=KM
51 | Congo=CG
52 | Congo=CD
53 | Cook Islands=CK
54 | Costa Rica=CR
55 | Côte d'Ivoire=CI
56 | Croatia=HR
57 | Cuba=CU
58 | Curaçao=CW
59 | Cyprus=CY
60 | Czechia=CZ
61 | Denmark=DK
62 | Djibouti=DJ
63 | Dominica=DM
64 | Dominican Republic=DO
65 | Ecuador=EC
66 | Egypt=EG
67 | El Salvador=SV
68 | Equatorial Guinea=GQ
69 | Eritrea=ER
70 | Estonia=EE
71 | Eswatini=SZ
72 | Ethiopia=ET
73 | Falkland Islands=FK
74 | Faroe Islands=FO
75 | Fiji=FJ
76 | Finland=FI
77 | France=FR
78 | French Guiana=GF
79 | French Polynesia=PF
80 | French Southern Territories=TF
81 | Gabon=GA
82 | Gambia=GM
83 | Georgia=GE
84 | Germany=DE
85 | Ghana=GH
86 | Gibraltar=GI
87 | Greece=GR
88 | Greenland=GL
89 | Grenada=GD
90 | Guadeloupe=GP
91 | Guam=GU
92 | Guatemala=GT
93 | Guernsey=GG
94 | Guinea=GN
95 | Guinea-Bissau=GW
96 | Guyana=GY
97 | Haiti=HT
98 | Heard Island and McDonald Islands=HM
99 | Holy See=VA
100 | Honduras=HN
101 | Hong Kong=HK
102 | Hungary=HU
103 | Iceland=IS
104 | India=IN
105 | Indonesia=ID
106 | Iran=IR
107 | Iraq=IQ
108 | Ireland=IE
109 | Isle of Man=IM
110 | Israel=IL
111 | Italy=IT
112 | Jamaica=JM
113 | Japan=JP
114 | Jersey=JE
115 | Jordan=JO
116 | Kazakhstan=KZ
117 | Kenya=KE
118 | Kiribati=KI
119 | North Korea=KP
120 | South Korea=KR
121 | Kuwait=KW
122 | Kyrgyzstan=KG
123 | Laos=LA
124 | Latvia=LV
125 | Lebanon=LB
126 | Lesotho=LS
127 | Liberia=LR
128 | Libya=LY
129 | Liechtenstein=LI
130 | Lithuania=LT
131 | Luxembourg=LU
132 | Macao=MO
133 | Madagascar=MG
134 | Malawi=MW
135 | Malaysia=MY
136 | Maldives=MV
137 | Mali=ML
138 | Malta=MT
139 | Marshall Islands=MH
140 | Martinique=MQ
141 | Mauritania=MR
142 | Mauritius=MU
143 | Mayotte=YT
144 | Mexico=MX
145 | Micronesia=FM
146 | Moldova=MD
147 | Monaco=MC
148 | Mongolia=MN
149 | Montenegro=ME
150 | Montserrat=MS
151 | Morocco=MA
152 | Mozambique=MZ
153 | Myanmar=MM
154 | Namibia=NA
155 | Nauru=NR
156 | Nepal=NP
157 | Netherlands=NL
158 | New Caledonia=NC
159 | New Zealand=NZ
160 | Nicaragua=NI
161 | Niger=NE
162 | Nigeria=NG
163 | Niue=NU
164 | Norfolk Island=NF
165 | North Macedonia=MK
166 | Northern Mariana Islands=MP
167 | Norway=NO
168 | Oman=OM
169 | Pakistan=PK
170 | Palau=PW
171 | Palestine, State of=PS
172 | Panama=PA
173 | Papua New Guinea=PG
174 | Paraguay=PY
175 | Peru=PE
176 | Philippines=PH
177 | Pitcairn=PN
178 | Poland=PL
179 | Portugal=PT
180 | Puerto Rico=PR
181 | Qatar=QA
182 | Réunion=RE
183 | Romania=RO
184 | Russia=RU
185 | Rwanda=RW
186 | Saint Barthélemy=BL
187 | Saint Helena=SH
188 | Saint Kitts and Nevis=KN
189 | Saint Lucia=LC
190 | Saint Martin=MF
191 | Saint Pierre and Miquelon=PM
192 | Saint Vincent=VC
193 | Samoa=WS
194 | San Marino=SM
195 | Sao Tome and Principe=ST
196 | Saudi Arabia=SA
197 | Senegal=SN
198 | Serbia=RS
199 | Seychelles=SC
200 | Sierra Leone=SL
201 | Singapore=SG
202 | Sint Maarten (Dutch part)=SX
203 | Slovakia=SK
204 | Slovenia=SI
205 | Solomon Islands=SB
206 | Somalia=SO
207 | South Africa=ZA
208 | South Georgia and the South Sandwich Islands=GS
209 | South Sudan=SS
210 | Spain=ES
211 | Sri Lanka=LK
212 | Sudan=SD
213 | Suriname=SR
214 | Svalbard and Jan Mayen=SJ
215 | Sweden=SE
216 | Switzerland=CH
217 | Syrian Arab Republic=SY
218 | Taiwan=TW
219 | Tajikistan=TJ
220 | Tanzania=TZ
221 | Thailand=TH
222 | Timor-Leste=TL
223 | Togo=TG
224 | Tokelau=TK
225 | Tonga=TO
226 | Trinidad and Tobago=TT
227 | Tunisia=TN
228 | Türkiye=TR
229 | Turkmenistan=TM
230 | Turks and Caicos Islands=TC
231 | Tuvalu=TV
232 | Uganda=UG
233 | Ukraine=UA
234 | United Arab Emirates=AE
235 | United Kingdom=GB
236 | United States of America=US
237 | United States Minor Outlying Islands=UM
238 | Uruguay=UY
239 | Uzbekistan=UZ
240 | Vanuatu=VU
241 | Venezuela=VE
242 | Viet Nam=VN
243 | Virgin Islands (British)=VG
244 | Virgin Islands (U.S.)=VI
245 | Wallis and Futuna=WF
246 | Western Sahara=EH
247 | Yemen=YE
248 | Zambia=ZM
249 | Zimbabwe=ZW
250 |
--------------------------------------------------------------------------------
/data/images/liwan-desktop-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/data/images/liwan-desktop-dark.png
--------------------------------------------------------------------------------
/data/images/liwan-desktop-full-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/data/images/liwan-desktop-full-dark.png
--------------------------------------------------------------------------------
/data/images/liwan-desktop-full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/data/images/liwan-desktop-full.png
--------------------------------------------------------------------------------
/data/images/liwan-desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/data/images/liwan-desktop.png
--------------------------------------------------------------------------------
/data/referrer_icons.txt:
--------------------------------------------------------------------------------
1 | Ozone=tencentqq
2 | Foursquare=foursquare
3 | Vkontakte=vk
4 | Weibo=sinaweibo
5 | Telegram=telegram
6 | Pixelfed=pixelfed
7 | Workplace=workplace
8 | X=x
9 | Threads=threads
10 | Mail.Ru=maildotru
11 | Hacker News=ycombinator
12 | TikTok=tiktok
13 | Facebook=facebook
14 | Last.fm=lastdotfm
15 | LinkedIn=linkedin
16 | Dribbble=dribbble
17 | reddit=reddit
18 | Flickr=flickr
19 | GitHub=github
20 | Pinterest=pinterest
21 | StackOverflow=stackoverflow
22 | Bluesky=bluesky
23 | LiveJournal=livejournal
24 | V2EX=v2ex
25 | Douban=douban
26 | Renren=renren
27 | tumblr=tumblr
28 | Snapchat=snapchat
29 | Badoo=badoo
30 | YouTube=youtube
31 | Instagram=instagram
32 | Viadeo=viadeo
33 | Odnoklassniki=odnoklassniki
34 | Vimeo=vimeo
35 | Mastodon=mastodon
36 | Sourceforge=sourceforge
37 | Twitch=twitch
38 | XING=xing
39 | Google=google
40 | DuckDuckGo=duckduckgo
--------------------------------------------------------------------------------
/scripts/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM debian:12-slim AS downloader
2 | ARG TAR_URL_AMD64
3 | ARG TAR_URL_ARM64
4 | ARG TARGETPLATFORM
5 |
6 | RUN apt-get update && apt-get install -y curl tar
7 | RUN echo "Downloading liwan for ${TARGETPLATFORM}..." \
8 | && TAR_URL=$(if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then echo ${TAR_URL_ARM64}; else echo ${TAR_URL_AMD64}; fi) \
9 | && curl -fsSL $TAR_URL -o /tmp/package.tar.gz \
10 | && mkdir -p /app \
11 | && tar -xzf /tmp/package.tar.gz -C /app \
12 | && chmod +x /app/liwan
13 |
14 | FROM alpine:3
15 |
16 | ENV LIWAN_CONFIG=/liwan.config.toml
17 | ENV LIWAN_DATA_DIR=/data
18 |
19 | COPY --from=downloader /app/liwan /liwan
20 | ENTRYPOINT ["/liwan"]
21 | EXPOSE 9042
--------------------------------------------------------------------------------
/scripts/build-licenses.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | cd "$(dirname "$0")" && cd ..
3 | cargo about generate --format json -o ./data/licenses-cargo.json
4 |
--------------------------------------------------------------------------------
/scripts/build-tracker.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | cd "$(dirname "$0")"
3 | esbuild ../tracker/script.ts --minify --format=esm --target=chrome123,edge123,firefox124,safari17 --outfile=../tracker/script.min.js
4 | bunx tsc ../tracker/script.ts --target ESNext --module ESNext --declaration --emitDeclarationOnly --outFile ../tracker/script.d.ts
5 |
--------------------------------------------------------------------------------
/scripts/deploy-demo.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | cd "$(dirname "$0")" && cd ..
3 | cd web && bun run build
4 | cd ..
5 | cargo zigbuild --release --target x86_64-unknown-linux-musl
6 | rsync -avzP target/x86_64-unknown-linux-musl/release/liwan pegasus:~/.local/bin/liwan
7 |
--------------------------------------------------------------------------------
/scripts/screenshot/.gitignore:
--------------------------------------------------------------------------------
1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
2 |
3 | # Logs
4 |
5 | logs
6 | _.log
7 | npm-debug.log_
8 | yarn-debug.log*
9 | yarn-error.log*
10 | lerna-debug.log*
11 | .pnpm-debug.log*
12 |
13 | # Caches
14 |
15 | .cache
16 |
17 | # Diagnostic reports (https://nodejs.org/api/report.html)
18 |
19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
20 |
21 | # Runtime data
22 |
23 | pids
24 | _.pid
25 | _.seed
26 | *.pid.lock
27 |
28 | # Directory for instrumented libs generated by jscoverage/JSCover
29 |
30 | lib-cov
31 |
32 | # Coverage directory used by tools like istanbul
33 |
34 | coverage
35 | *.lcov
36 |
37 | # nyc test coverage
38 |
39 | .nyc_output
40 |
41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
42 |
43 | .grunt
44 |
45 | # Bower dependency directory (https://bower.io/)
46 |
47 | bower_components
48 |
49 | # node-waf configuration
50 |
51 | .lock-wscript
52 |
53 | # Compiled binary addons (https://nodejs.org/api/addons.html)
54 |
55 | build/Release
56 |
57 | # Dependency directories
58 |
59 | node_modules/
60 | jspm_packages/
61 |
62 | # Snowpack dependency directory (https://snowpack.dev/)
63 |
64 | web_modules/
65 |
66 | # TypeScript cache
67 |
68 | *.tsbuildinfo
69 |
70 | # Optional npm cache directory
71 |
72 | .npm
73 |
74 | # Optional eslint cache
75 |
76 | .eslintcache
77 |
78 | # Optional stylelint cache
79 |
80 | .stylelintcache
81 |
82 | # Microbundle cache
83 |
84 | .rpt2_cache/
85 | .rts2_cache_cjs/
86 | .rts2_cache_es/
87 | .rts2_cache_umd/
88 |
89 | # Optional REPL history
90 |
91 | .node_repl_history
92 |
93 | # Output of 'npm pack'
94 |
95 | *.tgz
96 |
97 | # Yarn Integrity file
98 |
99 | .yarn-integrity
100 |
101 | # dotenv environment variable files
102 |
103 | .env
104 | .env.development.local
105 | .env.test.local
106 | .env.production.local
107 | .env.local
108 |
109 | # parcel-bundler cache (https://parceljs.org/)
110 |
111 | .parcel-cache
112 |
113 | # Next.js build output
114 |
115 | .next
116 | out
117 |
118 | # Nuxt.js build / generate output
119 |
120 | .nuxt
121 | dist
122 |
123 | # Gatsby files
124 |
125 | # Comment in the public line in if your project uses Gatsby and not Next.js
126 |
127 | # https://nextjs.org/blog/next-9-1#public-directory-support
128 |
129 | # public
130 |
131 | # vuepress build output
132 |
133 | .vuepress/dist
134 |
135 | # vuepress v2.x temp and cache directory
136 |
137 | .temp
138 |
139 | # Docusaurus cache and generated files
140 |
141 | .docusaurus
142 |
143 | # Serverless directories
144 |
145 | .serverless/
146 |
147 | # FuseBox cache
148 |
149 | .fusebox/
150 |
151 | # DynamoDB Local files
152 |
153 | .dynamodb/
154 |
155 | # TernJS port file
156 |
157 | .tern-port
158 |
159 | # Stores VSCode versions used for testing VSCode extensions
160 |
161 | .vscode-test
162 |
163 | # yarn v2
164 |
165 | .yarn/cache
166 | .yarn/unplugged
167 | .yarn/build-state.yml
168 | .yarn/install-state.gz
169 | .pnp.*
170 |
171 | # IntelliJ based IDEs
172 | .idea
173 |
174 | # Finder (MacOS) folder config
175 | .DS_Store
176 |
--------------------------------------------------------------------------------
/scripts/screenshot/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/explodingcamera/liwan/8968610f3f48886ec9ace3bb5a29187062f9a203/scripts/screenshot/bun.lockb
--------------------------------------------------------------------------------
/scripts/screenshot/index.ts:
--------------------------------------------------------------------------------
1 | import captureWebsite from "capture-website";
2 | import { join } from "node:path";
3 |
4 | const geoCardMargin = ".geocard { margin-bottom: 2rem !important; }";
5 |
6 | await captureWebsite.file(
7 | "http://localhost:4321/p/public-project",
8 | join(__dirname, "../../data/images/liwan-desktop.png"),
9 | {
10 | overwrite: true,
11 | width: 1100,
12 | height: 1480,
13 | quality: 0.8,
14 | styles: [geoCardMargin],
15 | },
16 | );
17 |
18 | await captureWebsite.file(
19 | "http://localhost:4321/p/public-project",
20 | join(__dirname, "../../data/images/liwan-desktop-dark.png"),
21 | {
22 | darkMode: true,
23 | overwrite: true,
24 | width: 1100,
25 | height: 1480,
26 | quality: 0.8,
27 | styles: [geoCardMargin],
28 | },
29 | );
30 |
31 | await captureWebsite.file(
32 | "http://localhost:4321/p/public-project",
33 | join(__dirname, "../../data/images/liwan-desktop-full.png"),
34 | {
35 | overwrite: true,
36 | width: 1100,
37 | fullPage: true,
38 | quality: 0.8,
39 | },
40 | );
41 |
42 | await captureWebsite.file(
43 | "http://localhost:4321/p/public-project",
44 | join(__dirname, "../../data/images/liwan-desktop-full-dark.png"),
45 | {
46 | darkMode: true,
47 | overwrite: true,
48 | width: 1100,
49 | fullPage: true,
50 | quality: 0.8,
51 | },
52 | );
53 |
--------------------------------------------------------------------------------
/scripts/screenshot/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "screenshot",
3 | "module": "index.ts",
4 | "type": "module",
5 | "devDependencies": {
6 | "@types/bun": "latest",
7 | "capture-website": "^4.1.0"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/core.rs:
--------------------------------------------------------------------------------
1 | pub mod entities;
2 | pub mod events;
3 | pub mod onboarding;
4 | pub mod projects;
5 | pub mod reports;
6 | mod reports_cached;
7 | pub mod sessions;
8 | pub mod users;
9 |
10 | pub use entities::LiwanEntities;
11 | pub use events::LiwanEvents;
12 | pub use onboarding::LiwanOnboarding;
13 | pub use projects::LiwanProjects;
14 | pub use sessions::LiwanSessions;
15 | pub use users::LiwanUsers;
16 |
17 | #[cfg(feature = "geoip")]
18 | pub mod geoip;
19 |
--------------------------------------------------------------------------------
/src/app/core/entities.rs:
--------------------------------------------------------------------------------
1 | use eyre::{Result, bail};
2 |
3 | use crate::app::{SqlitePool, models};
4 | use crate::utils::validate;
5 |
6 | #[derive(Clone)]
7 | pub struct LiwanEntities {
8 | pool: SqlitePool,
9 | }
10 |
11 | impl LiwanEntities {
12 | pub fn new(pool: SqlitePool) -> Self {
13 | Self { pool }
14 | }
15 |
16 | /// Get all entities
17 | pub fn all(&self) -> Result> {
18 | let conn = self.pool.get()?;
19 | let mut stmt = conn.prepare("select id, display_name from entities")?;
20 | let entities = stmt
21 | .query_map([], |row| Ok(models::Entity { id: row.get("id")?, display_name: row.get("display_name")? }))?;
22 | Ok(entities.collect::, rusqlite::Error>>()?)
23 | }
24 |
25 | /// Create a new entity
26 | pub fn create(&self, entity: &models::Entity, initial_projects: &[String]) -> Result<()> {
27 | if !validate::is_valid_id(&entity.id) {
28 | bail!("invalid entity ID");
29 | }
30 |
31 | let mut conn = self.pool.get()?;
32 | let tx = conn.transaction()?;
33 | tx.execute(
34 | "insert into entities (id, display_name) values (?, ?)",
35 | rusqlite::params![entity.id, entity.display_name],
36 | )?;
37 | for project_id in initial_projects {
38 | tx.execute(
39 | "insert into project_entities (project_id, entity_id) values (?, ?)",
40 | rusqlite::params![project_id, entity.id],
41 | )?;
42 | }
43 | tx.commit()?;
44 | Ok(())
45 | }
46 |
47 | /// Update an entity
48 | pub fn update(&self, entity: &models::Entity) -> Result {
49 | let conn = self.pool.get()?;
50 | let mut stmt = conn.prepare_cached("update entities set display_name = ? where id = ?")?;
51 | stmt.execute(rusqlite::params![entity.display_name, entity.id])?;
52 | Ok(entity.clone())
53 | }
54 |
55 | /// Update an entity's projects
56 | pub fn update_projects(&self, entity_id: &str, project_ids: &[String]) -> Result<()> {
57 | let mut conn = self.pool.get()?;
58 | let tx = conn.transaction()?;
59 | tx.execute("delete from project_entities where entity_id = ?", rusqlite::params![entity_id])?;
60 | for project_id in project_ids {
61 | tx.execute(
62 | "insert into project_entities (project_id, entity_id) values (?, ?)",
63 | rusqlite::params![project_id, entity_id],
64 | )?;
65 | }
66 | tx.commit()?;
67 | Ok(())
68 | }
69 |
70 | /// Delete an entity (does not remove associated events)
71 | pub fn delete(&self, id: &str) -> Result<()> {
72 | let mut conn = self.pool.get()?;
73 | let tx = conn.transaction()?;
74 | tx.execute("delete from entities where id = ?", rusqlite::params![id])?;
75 | tx.execute("delete from project_entities where entity_id = ?", rusqlite::params![id])?;
76 | tx.commit()?;
77 | Ok(())
78 | }
79 |
80 | /// Get all projects associated with an entity
81 | pub fn projects(&self, entity_id: &str) -> Result> {
82 | let conn = self.pool.get()?;
83 | let mut stmt = conn.prepare_cached(
84 | "select p.id, p.display_name, p.public, p.secret from projects p join project_entities pe on p.id = pe.project_id where pe.entity_id = ?",
85 | )?;
86 | let projects = stmt.query_map(rusqlite::params![entity_id], |row| {
87 | Ok(models::Project {
88 | id: row.get("id")?,
89 | display_name: row.get("display_name")?,
90 | public: row.get("public")?,
91 | secret: row.get("secret")?,
92 | })
93 | })?;
94 | Ok(projects.collect::, rusqlite::Error>>()?)
95 | }
96 |
97 | /// Check if an entity exists
98 | pub fn exists(&self, id: &str) -> Result {
99 | let conn = self.pool.get()?;
100 | let mut stmt = conn.prepare_cached("select 1 from entities where id = ? limit 1")?;
101 | Ok(stmt.exists([id])?)
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/app/core/onboarding.rs:
--------------------------------------------------------------------------------
1 | use std::sync::Arc;
2 |
3 | use crossbeam_utils::sync::ShardedLock;
4 | use eyre::Result;
5 |
6 | use crate::{app::SqlitePool, utils::hash::onboarding_token};
7 |
8 | #[derive(Clone)]
9 | pub struct LiwanOnboarding {
10 | token: Arc>>,
11 | }
12 |
13 | impl LiwanOnboarding {
14 | pub fn try_new(pool: &SqlitePool) -> Result {
15 | let onboarding = {
16 | tracing::debug!("Checking if an onboarding token needs to be generated");
17 | let conn = pool.get()?;
18 | let mut stmt = conn.prepare("select 1 from users limit 1")?;
19 | ShardedLock::new(if stmt.exists([])? { None } else { Some(onboarding_token()) })
20 | };
21 |
22 | Ok(Self { token: onboarding.into() })
23 | }
24 |
25 | /// Get the onboarding token, if it exists
26 | pub fn token(&self) -> Result