├── .env.dist ├── .gitattributes ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .release-it.json ├── LICENSE ├── README.md ├── api ├── app.js ├── db │ ├── config.js │ ├── index.js │ └── migrations │ │ ├── 20200713072454_create_uuid_ossp_extension.js │ │ ├── 20200713072746_create_users.js │ │ ├── 20200713073113_create_projects.js │ │ ├── 20200713073310_create_counts.js │ │ └── 20201007055505_add_shared_secret_to_projects.js ├── initializers │ ├── index.js │ ├── objection.js │ └── pg.js ├── lib │ ├── paths.js │ ├── pick.js │ └── px.js ├── models │ ├── base.js │ ├── count.js │ ├── project.js │ └── user.js ├── plugins │ └── auth.js ├── routes │ ├── api │ │ ├── counts │ │ │ ├── autohooks.js │ │ │ └── get.js │ │ ├── get.js │ │ ├── projects │ │ │ ├── _id │ │ │ │ ├── autohooks.js │ │ │ │ └── put.js │ │ │ ├── autohooks.js │ │ │ ├── current.js │ │ │ └── post.js │ │ └── time_zones │ │ │ └── get.js │ ├── ship │ │ └── get.js │ └── web.js └── services │ ├── counts │ ├── count_incrementor.js │ ├── count_pruner.js │ └── count_request_data.js │ ├── cron_job_scheduler.js │ ├── errors │ ├── app_error.js │ ├── base_error.js │ ├── error_handler.js │ └── error_map.js │ ├── projects │ ├── project_creator.js │ ├── project_secret_generator.js │ └── project_updater.js │ └── users │ ├── user_creator.js │ └── user_updater.js ├── compose.yaml ├── eslint.config.js ├── jsconfig.json ├── package-lock.json ├── package.json └── web ├── .env.development ├── .env.production ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico └── robots.txt ├── src ├── App.vue ├── assets │ └── logo.svg ├── components │ ├── base │ │ ├── BaseAlert.vue │ │ ├── BaseButton.vue │ │ ├── BaseCenter.vue │ │ ├── BaseIcon.vue │ │ ├── BaseIconChevronBottom.vue │ │ ├── BaseIconChevronLeft.vue │ │ ├── BaseIconSettings.vue │ │ ├── BaseSelect.vue │ │ └── BaseTextInput.vue │ ├── project │ │ ├── ProjectForm.vue │ │ ├── ProjectIndex.vue │ │ ├── ProjectSettings.vue │ │ └── dashboard │ │ │ ├── ProjectDashboard.vue │ │ │ ├── ProjectDashboardHeader.vue │ │ │ ├── ProjectDashboardRecord.vue │ │ │ ├── ProjectDashboardRecordCount.vue │ │ │ ├── ProjectDashboardScrollable.vue │ │ │ └── ProjectDashboardWelcome.vue │ ├── signup │ │ └── SignupIndex.vue │ └── the │ │ ├── TheLayout.vue │ │ └── ThePiratepx.vue ├── lib │ └── to_iso_date_string.js ├── main.css ├── main.js ├── plugins │ └── page_title.js ├── router │ └── index.js ├── services │ ├── api.js │ └── format_number.js ├── store │ ├── index.js │ └── modules │ │ ├── counts.js │ │ ├── projects │ │ ├── current.js │ │ └── index.js │ │ ├── signup.js │ │ └── time_zones.js └── views │ ├── project │ ├── ProjectDashboardView.vue │ ├── ProjectIndexView.vue │ └── ProjectSettingsView.vue │ └── signup │ └── SignupIndexView.vue ├── tailwind.config.js └── vite.config.js /.env.dist: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | 3 | DATABASE_URL=postgres://postgres:password@localhost:5432/piratepx_development 4 | PORT=3000 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | /node_modules 3 | 4 | # dotenv 5 | .env 6 | 7 | # Logs 8 | npm-debug.log* 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /web 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "always", 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "v${version}", 4 | "tagName": "v${version}", 5 | "tagAnnotation": "v${version}" 6 | }, 7 | "github": { 8 | "release": true, 9 | "releaseName": "v${version}" 10 | }, 11 | "npm": { 12 | "publish": false 13 | }, 14 | "plugins": { 15 | "@release-it/bumper": { 16 | "out": ["web/package.json", "web/package-lock.json"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 piratepx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🏴‍☠️ piratepx 2 | 3 | A simple, privacy-respecting, no cookie, zero JavaScript, 35 byte counter pixel 4 | for websites, mobile apps, server-side APIs, CLIs, and just about anywhere else. 5 | 6 | Sign up for free at https://www.piratepx.com! 7 | ![piratepx](https://app.piratepx.com/ship?p=54c04676-e9cf-4ca8-8934-78a629eb4a2c&i=app) 8 | 9 | ## Overview 10 | 11 | This repository contains both the backend and frontend of the app to simplify 12 | development and deployment. Other than a few lines of configuration here and 13 | there to make this possible, however, they're pretty much separate codebases. 14 | 15 | ### Backend 16 | 17 | The backend is a JSON REST API built in [Node.js](https://nodejs.org/) using 18 | [Fastify](https://www.fastify.dev/) and 19 | [Objection.js](https://vincit.github.io/objection.js/). It persists data to a 20 | [PostgreSQL](https://www.postgresql.org/) database. 21 | 22 | The source code is located in the [`api`](api) directory, with configuration 23 | files in the root of this repository (where this README lives). 24 | 25 | ### Frontend 26 | 27 | The frontend is a single-page app built with [Vue.js](https://vuejs.org/) and 28 | [Tailwind CSS](https://tailwindcss.com/). 29 | 30 | The source code is fully isolated in the [`web`](web) directory, which is also 31 | where its own configuration files are located. 32 | 33 | ## Development 34 | 35 | The following includes the necessary steps to get the full app setup for 36 | development, with a focus on backend-specific details. See 37 | [`web/README.md`](web/README.md) for frontend-specific details. 38 | 39 | ### Prerequisites 40 | 41 | - [Node.js](https://nodejs.org/) (see `engines.node` in 42 | [`package.json`](package.json)) 43 | - [PostgreSQL](https://www.postgresql.org/) >= v16 44 | 45 | [Docker Compose](https://docs.docker.com/compose/) is used to run PostgreSQL as 46 | configured in [`compose.yaml`](compose.yaml). Once installed, simply run: 47 | 48 | ```bash 49 | $ docker compose up 50 | ``` 51 | 52 | The app itself is not run in a Docker container in development, as it's easy 53 | enough to install the necessary version of Node.js with 54 | [nvm](https://github.com/nvm-sh/nvm): 55 | 56 | ```bash 57 | $ nvm install 58 | ``` 59 | 60 | ### Dependencies 61 | 62 | Install dependencies with npm: 63 | 64 | ```bash 65 | $ npm install 66 | $ cd web && npm install 67 | ``` 68 | 69 | ### Config 70 | 71 | [dotenv](https://github.com/motdotla/dotenv) is used to load environment 72 | variables from a `.env` file into `process.env`. This file is ignored by version 73 | control to prevent committing secrets. 74 | 75 | See [`.env.dist`](.env.dist) for an example. 76 | 77 | ### Database 78 | 79 | #### Migrations 80 | 81 | [Knex.js](https://knexjs.org/guide/migrations.html) is used to manage database 82 | migrations, which are located in [`api/db/migrations`](api/db/migrations). 83 | 84 | To run the latest migrations: 85 | 86 | ```bash 87 | $ npm run knex migrate:latest 88 | ``` 89 | 90 | ### Start 91 | 92 | Start both the backend and frontend development servers: 93 | 94 | ```bash 95 | $ npm run dev 96 | ``` 97 | 98 | ### Code Style & Linting 99 | 100 | [Prettier](https://prettier.io/) is setup to enforce a consistent code style. 101 | It's highly recommended to 102 | [add an integration to your editor](https://prettier.io/docs/en/editors.html) 103 | that automatically formats on save. 104 | 105 | [ESLint](https://eslint.org/) is setup with the 106 | ["recommended" rules](https://eslint.org/docs/latest/rules/) to enforce a level 107 | of code quality. It's also highly recommended to 108 | [add an integration to your editor](https://eslint.org/docs/latest/use/integrations#editors) 109 | that automatically formats on save. 110 | 111 | To run via the command line: 112 | 113 | ```bash 114 | $ npm run lint 115 | ``` 116 | 117 | ## Releasing 118 | 119 | After development is done in the `development` branch and is ready for release, 120 | it should be merged into the `main` branch, where the latest release code lives. 121 | [Release It!](https://github.com/release-it/release-it) is then used to 122 | interactively orchestrate the release process: 123 | 124 | ```bash 125 | $ npm run release 126 | ``` 127 | -------------------------------------------------------------------------------- /api/app.js: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path' 2 | 3 | import autoLoad from '@fastify/autoload' 4 | 5 | import initializers from '#api/initializers/index' 6 | 7 | import { apiDir } from '#api/lib/paths' 8 | import cronJobScheduler from '#api/services/cron_job_scheduler' 9 | import errorHandler, { errorSchema } from '#api/services/errors/error_handler' 10 | 11 | // eslint-disable-next-line no-unused-vars 12 | async function app(fastify, options) { 13 | await initializers() 14 | 15 | fastify.addSchema(errorSchema) 16 | fastify.setErrorHandler(errorHandler) 17 | 18 | await fastify.register(autoLoad, { 19 | dir: join(apiDir, 'plugins'), 20 | encapsulate: false, 21 | }) 22 | 23 | fastify.register(autoLoad, { 24 | dir: join(apiDir, 'routes'), 25 | autoHooks: true, 26 | cascadeHooks: true, 27 | routeParams: true, 28 | }) 29 | 30 | cronJobScheduler() 31 | } 32 | 33 | export default app 34 | -------------------------------------------------------------------------------- /api/db/config.js: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | 3 | const defaults = { 4 | client: 'pg', 5 | connection: env.DATABASE_URL, 6 | pool: { 7 | min: 0, 8 | max: 10, 9 | }, 10 | } 11 | 12 | const config = { 13 | development: { 14 | ...defaults, 15 | debug: true, 16 | }, 17 | production: { 18 | ...defaults, 19 | }, 20 | } 21 | 22 | export default config 23 | -------------------------------------------------------------------------------- /api/db/index.js: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | import { env } from 'node:process' 3 | 4 | import config from '#api/db/config' 5 | 6 | const db = Knex(config[env.NODE_ENV]) 7 | 8 | export default db 9 | -------------------------------------------------------------------------------- /api/db/migrations/20200713072454_create_uuid_ossp_extension.js: -------------------------------------------------------------------------------- 1 | async function up(knex) { 2 | return await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') 3 | } 4 | 5 | async function down(knex) { 6 | return await knex.raw('DROP EXTENSION IF EXISTS "uuid-ossp"') 7 | } 8 | 9 | export { up, down } 10 | -------------------------------------------------------------------------------- /api/db/migrations/20200713072746_create_users.js: -------------------------------------------------------------------------------- 1 | async function up(knex) { 2 | return await knex.schema.createTable('users', (t) => { 3 | t.uuid('id') 4 | .defaultTo(knex.raw('uuid_generate_v4()')) 5 | .notNullable() 6 | .primary() 7 | t.string('email').notNullable().unique() 8 | t.datetime('created_at').notNullable() 9 | t.datetime('updated_at').notNullable() 10 | }) 11 | } 12 | 13 | async function down(knex) { 14 | return await knex.schema.dropTable('users') 15 | } 16 | 17 | export { up, down } 18 | -------------------------------------------------------------------------------- /api/db/migrations/20200713073113_create_projects.js: -------------------------------------------------------------------------------- 1 | async function up(knex) { 2 | return await knex.schema.createTable('projects', (t) => { 3 | t.uuid('id') 4 | .defaultTo(knex.raw('uuid_generate_v4()')) 5 | .notNullable() 6 | .primary() 7 | t.uuid('user_id').notNullable().references('users.id').index() 8 | t.string('name').notNullable() 9 | t.string('time_zone').notNullable() 10 | t.string('secret').notNullable().unique() 11 | t.datetime('created_at').notNullable() 12 | t.datetime('updated_at').notNullable() 13 | }) 14 | } 15 | 16 | async function down(knex) { 17 | return await knex.schema.dropTable('projects') 18 | } 19 | 20 | export { up, down } 21 | -------------------------------------------------------------------------------- /api/db/migrations/20200713073310_create_counts.js: -------------------------------------------------------------------------------- 1 | async function up(knex) { 2 | return await knex.schema.createTable('counts', (t) => { 3 | t.uuid('id') 4 | .defaultTo(knex.raw('uuid_generate_v4()')) 5 | .notNullable() 6 | .primary() 7 | t.uuid('project_id').notNullable().references('projects.id') 8 | t.string('identifier').notNullable() 9 | t.date('date').notNullable().index() 10 | t.integer('count').notNullable().defaultTo(0) 11 | t.datetime('created_at').notNullable() 12 | t.datetime('updated_at').notNullable() 13 | 14 | t.unique(['project_id', 'identifier', 'date']) 15 | }) 16 | } 17 | 18 | async function down(knex) { 19 | return await knex.schema.dropTable('counts') 20 | } 21 | 22 | export { up, down } 23 | -------------------------------------------------------------------------------- /api/db/migrations/20201007055505_add_shared_secret_to_projects.js: -------------------------------------------------------------------------------- 1 | async function up(knex) { 2 | return await knex.schema.table('projects', (t) => { 3 | t.string('shared_secret').unique() 4 | }) 5 | } 6 | 7 | async function down(knex) { 8 | return await knex.schema.table('projects', (t) => { 9 | t.dropColumn('shared_secret') 10 | }) 11 | } 12 | 13 | export { up, down } 14 | -------------------------------------------------------------------------------- /api/initializers/index.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | import initializeObjection from '#api/initializers/objection' 4 | import initializePG from '#api/initializers/pg' 5 | 6 | async function initializers() { 7 | initializePG() 8 | await initializeObjection() 9 | } 10 | 11 | export default initializers 12 | -------------------------------------------------------------------------------- /api/initializers/objection.js: -------------------------------------------------------------------------------- 1 | import { Model } from 'objection' 2 | 3 | import db from '#api/db/index' 4 | 5 | async function initializeObjection() { 6 | Model.knex(db) 7 | 8 | await db.raw("SELECT 'Hello?'") 9 | } 10 | 11 | export default initializeObjection 12 | -------------------------------------------------------------------------------- /api/initializers/pg.js: -------------------------------------------------------------------------------- 1 | import pg from 'pg' 2 | 3 | function initializePG() { 4 | // Parse date type as string instead of Date to prevent time zone conversion. 5 | // See: https://github.com/brianc/node-postgres/issues/1844 6 | pg.types.setTypeParser(1082, (date) => date) 7 | } 8 | 9 | export default initializePG 10 | -------------------------------------------------------------------------------- /api/lib/paths.js: -------------------------------------------------------------------------------- 1 | import { dirname, join } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..') 5 | const apiDir = join(rootDir, 'api') 6 | const webDir = join(rootDir, 'web') 7 | 8 | export { rootDir, apiDir, webDir } 9 | -------------------------------------------------------------------------------- /api/lib/pick.js: -------------------------------------------------------------------------------- 1 | function pick(obj, keys) { 2 | if (!obj) { 3 | return null 4 | } 5 | 6 | const picked = {} 7 | 8 | keys.forEach((key) => { 9 | if (key in obj) { 10 | picked[key] = obj[key] 11 | } 12 | }) 13 | 14 | return picked 15 | } 16 | 17 | export default pick 18 | -------------------------------------------------------------------------------- /api/lib/px.js: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer' 2 | 3 | const px = Buffer.from( 4 | 'R0lGODlhAQABAHAAACH5BAUAAAAALAAAAAABAAEAAAICRAEAOw==', 5 | 'base64', 6 | ) 7 | 8 | export default px 9 | -------------------------------------------------------------------------------- /api/models/base.js: -------------------------------------------------------------------------------- 1 | import { Model } from 'objection' 2 | 3 | class BaseModel extends Model { 4 | async $beforeInsert(queryContext) { 5 | await super.$beforeInsert(queryContext) 6 | 7 | const date = new Date().toISOString() 8 | 9 | this.created_at = date 10 | this.updated_at = date 11 | 12 | await this.beforeSaveValidation(null, queryContext) 13 | } 14 | 15 | async $beforeUpdate(opt, queryContext) { 16 | await super.$beforeUpdate(opt, queryContext) 17 | 18 | this.updated_at = new Date().toISOString() 19 | 20 | await this.beforeSaveValidation(opt, queryContext) 21 | } 22 | 23 | // eslint-disable-next-line no-unused-vars 24 | async beforeSaveValidation(opt, queryContext) {} 25 | 26 | values(fieldOrFields) { 27 | const fields = Array.isArray(fieldOrFields) 28 | ? fieldOrFields 29 | : [fieldOrFields] 30 | 31 | return fields.reduce( 32 | (accumulator, field) => ({ 33 | ...accumulator, 34 | [field]: this[field], 35 | }), 36 | {}, 37 | ) 38 | } 39 | 40 | static async isUnique(where, transaction = null) { 41 | const model = await this.query(transaction).findOne(where) 42 | 43 | return !model 44 | } 45 | 46 | async validateUniqueness(fieldOrFields, opt, queryContext) { 47 | const values = this.values(fieldOrFields) 48 | const fields = Object.keys(values) 49 | 50 | const everyValueEmpty = Object.values(values).every( 51 | (value) => value === undefined || value === null, 52 | ) 53 | 54 | if (everyValueEmpty) { 55 | return true 56 | } 57 | 58 | const id = opt?.old?.id 59 | 60 | const isUnique = await this.constructor.isUnique((builder) => { 61 | builder.where(values) 62 | 63 | if (id) { 64 | builder.whereNot('id', id) 65 | } 66 | }, queryContext.transaction) 67 | 68 | if (!isUnique) { 69 | throw this.buildValidationError(fields.join(' + '), 'already exists') 70 | } 71 | 72 | return true 73 | } 74 | 75 | buildValidationError(field, message) { 76 | return new this.constructor.ValidationError({ 77 | type: 'ModelValidation', 78 | data: { 79 | [field]: [{ message }], 80 | }, 81 | }) 82 | } 83 | 84 | static async findOneOrInsert( 85 | findOneWhere, 86 | insertModelOrObject = null, 87 | transaction = null, 88 | ) { 89 | let model = await this.query(transaction).findOne(findOneWhere) 90 | 91 | if (model) { 92 | return model 93 | } 94 | 95 | try { 96 | model = await this.query(transaction) 97 | .insert(insertModelOrObject || findOneWhere) 98 | .returning('*') 99 | } catch (error) { 100 | model = await this.query(transaction).findOne(findOneWhere) 101 | 102 | if (!model) { 103 | throw error 104 | } 105 | } 106 | 107 | return model 108 | } 109 | } 110 | 111 | export default BaseModel 112 | -------------------------------------------------------------------------------- /api/models/count.js: -------------------------------------------------------------------------------- 1 | import BaseModel from '#api/models/base' 2 | import Project from '#api/models/project' 3 | 4 | class Count extends BaseModel { 5 | static tableName = 'counts' 6 | 7 | static relationMappings = () => ({ 8 | project: { 9 | relation: this.BelongsToOneRelation, 10 | modelClass: Project, 11 | join: { 12 | from: 'counts.project_id', 13 | to: 'projects.id', 14 | }, 15 | }, 16 | }) 17 | 18 | static jsonSchema = { 19 | type: 'object', 20 | properties: { 21 | id: { 22 | type: 'string', 23 | format: 'uuid', 24 | }, 25 | project_id: { 26 | type: 'string', 27 | format: 'uuid', 28 | }, 29 | identifier: { 30 | type: 'string', 31 | minLength: 1, 32 | maxLength: 255, 33 | }, 34 | date: { 35 | type: 'string', 36 | format: 'date', 37 | }, 38 | count: { 39 | type: 'integer', 40 | default: 0, 41 | }, 42 | created_at: { 43 | type: 'string', 44 | format: 'date-time', 45 | }, 46 | updated_at: { 47 | type: 'string', 48 | format: 'date-time', 49 | }, 50 | }, 51 | required: ['project_id', 'identifier', 'date'], 52 | } 53 | 54 | async beforeSaveValidation(opt, queryContext) { 55 | await this.validateUniqueness( 56 | ['project_id', 'identifier', 'date'], 57 | opt, 58 | queryContext, 59 | ) 60 | } 61 | } 62 | 63 | export default Count 64 | -------------------------------------------------------------------------------- /api/models/project.js: -------------------------------------------------------------------------------- 1 | import { timeZonesNames } from '@vvo/tzdb' 2 | 3 | import BaseModel from '#api/models/base' 4 | import Count from '#api/models/count' 5 | import User from '#api/models/user' 6 | 7 | class Project extends BaseModel { 8 | static tableName = 'projects' 9 | 10 | static relationMappings = () => ({ 11 | counts: { 12 | relation: this.HasManyRelation, 13 | modelClass: Count, 14 | join: { 15 | from: 'projects.id', 16 | to: 'counts.project_id', 17 | }, 18 | }, 19 | user: { 20 | relation: this.BelongsToOneRelation, 21 | modelClass: User, 22 | join: { 23 | from: 'projects.user_id', 24 | to: 'users.id', 25 | }, 26 | }, 27 | }) 28 | 29 | static jsonSchema = { 30 | type: 'object', 31 | properties: { 32 | id: { 33 | type: 'string', 34 | format: 'uuid', 35 | }, 36 | user_id: { 37 | type: 'string', 38 | format: 'uuid', 39 | }, 40 | name: { 41 | type: 'string', 42 | minLength: 1, 43 | maxLength: 255, 44 | }, 45 | time_zone: { 46 | type: 'string', 47 | minLength: 1, 48 | maxLength: 255, 49 | enum: timeZonesNames, 50 | }, 51 | secret: { 52 | type: 'string', 53 | minLength: 1, 54 | maxLength: 255, 55 | }, 56 | shared_secret: { 57 | type: ['string', 'null'], 58 | minLength: 1, 59 | maxLength: 255, 60 | }, 61 | created_at: { 62 | type: 'string', 63 | format: 'date-time', 64 | }, 65 | updated_at: { 66 | type: 'string', 67 | format: 'date-time', 68 | }, 69 | }, 70 | required: ['user_id', 'name', 'time_zone', 'secret'], 71 | } 72 | 73 | async beforeSaveValidation(opt, queryContext) { 74 | await this.validateUniqueness('secret', opt, queryContext) 75 | await this.validateUniqueness('shared_secret', opt, queryContext) 76 | } 77 | } 78 | 79 | export default Project 80 | -------------------------------------------------------------------------------- /api/models/user.js: -------------------------------------------------------------------------------- 1 | import BaseModel from '#api/models/base' 2 | import Project from '#api/models/project' 3 | 4 | class User extends BaseModel { 5 | static tableName = 'users' 6 | 7 | static relationMappings = () => ({ 8 | projects: { 9 | relation: this.HasManyRelation, 10 | modelClass: Project, 11 | join: { 12 | from: 'users.id', 13 | to: 'projects.user_id', 14 | }, 15 | }, 16 | }) 17 | 18 | static jsonSchema = { 19 | type: 'object', 20 | properties: { 21 | id: { 22 | type: 'string', 23 | format: 'uuid', 24 | }, 25 | email: { 26 | type: 'string', 27 | format: 'email', 28 | minLength: 1, 29 | maxLength: 255, 30 | }, 31 | created_at: { 32 | type: 'string', 33 | format: 'date-time', 34 | }, 35 | updated_at: { 36 | type: 'string', 37 | format: 'date-time', 38 | }, 39 | }, 40 | required: ['email'], 41 | } 42 | 43 | async beforeSaveValidation(opt, queryContext) { 44 | await this.validateUniqueness('email', opt, queryContext) 45 | } 46 | } 47 | 48 | export default User 49 | -------------------------------------------------------------------------------- /api/plugins/auth.js: -------------------------------------------------------------------------------- 1 | import Project from '#api/models/project' 2 | import AppError from '#api/services/errors/app_error' 3 | 4 | const SHARED_SECRET_PREFIX = 'shared/' 5 | 6 | async function auth(fastify) { 7 | fastify.decorateRequest('currentProject') 8 | 9 | await fastify.register(import('@fastify/bearer-auth'), { 10 | async auth(secret, request) { 11 | const isShared = secret.startsWith(SHARED_SECRET_PREFIX) 12 | 13 | const project = await Project.query() 14 | .withGraphFetched('user') 15 | .findOne( 16 | isShared ? 'shared_secret' : 'secret', 17 | isShared ? secret.substring(SHARED_SECRET_PREFIX.length) : secret, 18 | ) 19 | 20 | if (!project) { 21 | return false 22 | } 23 | 24 | project.is_shared = isShared 25 | 26 | request.currentProject = project 27 | 28 | return true 29 | }, 30 | addHook: false, 31 | // Disable error logging, as it's already done by our error handler. 32 | verifyErrorLogLevel: null, 33 | }) 34 | 35 | fastify.decorate('auth', ({ allowShared = false } = {}) => [ 36 | fastify.verifyBearerAuth, 37 | async (request) => { 38 | if (request.currentProject.is_shared && !allowShared) { 39 | throw new AppError(403, 'Shared secret does not have permission') 40 | } 41 | }, 42 | ]) 43 | } 44 | 45 | export default auth 46 | -------------------------------------------------------------------------------- /api/routes/api/counts/autohooks.js: -------------------------------------------------------------------------------- 1 | export default async (fastify) => { 2 | fastify.addSchema({ 3 | $id: 'count', 4 | type: 'object', 5 | properties: { 6 | id: { 7 | type: 'string', 8 | format: 'uuid', 9 | }, 10 | identifier: { 11 | type: 'string', 12 | minLength: 1, 13 | maxLength: 255, 14 | }, 15 | date: { 16 | type: 'string', 17 | format: 'date', 18 | }, 19 | count: { 20 | type: 'integer', 21 | }, 22 | created_at: { 23 | type: 'string', 24 | format: 'date-time', 25 | }, 26 | updated_at: { 27 | type: 'string', 28 | format: 'date-time', 29 | }, 30 | }, 31 | required: ['id', 'identifier', 'date', 'count', 'created_at', 'updated_at'], 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /api/routes/api/counts/get.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | 3 | import Count from '#api/models/count' 4 | 5 | export default async (fastify) => { 6 | fastify.get( 7 | '/', 8 | { 9 | preValidation: fastify.auth({ allowShared: true }), 10 | schema: { 11 | response: { 12 | 200: { 13 | type: 'object', 14 | properties: { 15 | counts: { 16 | type: 'array', 17 | items: { 18 | $ref: 'count', 19 | }, 20 | }, 21 | }, 22 | required: ['counts'], 23 | }, 24 | '4xx': { 25 | $ref: 'error', 26 | }, 27 | '5xx': { 28 | $ref: 'error', 29 | }, 30 | }, 31 | }, 32 | }, 33 | async (request) => { 34 | const counts = await Count.query() 35 | .where({ project_id: request.currentProject.id }) 36 | .where( 37 | 'date', 38 | '>', 39 | DateTime.local() 40 | .setZone(request.currentProject.time_zone) 41 | .minus({ days: 30 }) 42 | .toSQLDate(), 43 | ) 44 | .orderBy(['identifier', 'date']) 45 | 46 | return { counts } 47 | }, 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /api/routes/api/get.js: -------------------------------------------------------------------------------- 1 | export default async (fastify) => { 2 | fastify.get('/', { logLevel: 'silent' }, async () => { 3 | return 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /api/routes/api/projects/_id/autohooks.js: -------------------------------------------------------------------------------- 1 | import Project from '#api/models/project' 2 | import AppError from '#api/services/errors/app_error' 3 | 4 | export default async (fastify) => { 5 | fastify.decorateRequest('project') 6 | 7 | fastify.addHook('preHandler', async (request) => { 8 | const id = request.params.id 9 | 10 | if (id !== request.currentProject.id) { 11 | throw new AppError(403, 'Only the authorized project can be updated') 12 | } 13 | 14 | request.project = await Project.query() 15 | .withGraphFetched('user') 16 | .findOne({ id }) 17 | .throwIfNotFound() 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /api/routes/api/projects/_id/put.js: -------------------------------------------------------------------------------- 1 | import { timeZonesNames } from '@vvo/tzdb' 2 | 3 | import ProjectUpdater from '#api/services/projects/project_updater' 4 | 5 | export default async (fastify) => { 6 | fastify.put( 7 | '', 8 | { 9 | preValidation: fastify.auth(), 10 | schema: { 11 | params: { 12 | type: 'object', 13 | properties: { 14 | id: { 15 | type: 'string', 16 | format: 'uuid', 17 | }, 18 | }, 19 | }, 20 | body: { 21 | type: 'object', 22 | properties: { 23 | project: { 24 | type: 'object', 25 | properties: { 26 | name: { 27 | type: 'string', 28 | minLength: 1, 29 | maxLength: 255, 30 | }, 31 | time_zone: { 32 | type: 'string', 33 | minLength: 1, 34 | maxLength: 255, 35 | enum: timeZonesNames, 36 | }, 37 | allow_shared: { type: 'boolean' }, 38 | user: { 39 | type: 'object', 40 | properties: { 41 | email: { 42 | type: 'string', 43 | format: 'email', 44 | minLength: 1, 45 | maxLength: 255, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | required: ['project'], 53 | }, 54 | response: { 55 | 200: { 56 | $ref: 'project', 57 | }, 58 | '4xx': { 59 | $ref: 'error', 60 | }, 61 | '5xx': { 62 | $ref: 'error', 63 | }, 64 | }, 65 | }, 66 | }, 67 | async (request) => { 68 | const updater = new ProjectUpdater({ 69 | data: request.body.project, 70 | project: request.project, 71 | }) 72 | 73 | await updater.update() 74 | 75 | return { project: request.project } 76 | }, 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /api/routes/api/projects/autohooks.js: -------------------------------------------------------------------------------- 1 | import { timeZonesNames } from '@vvo/tzdb' 2 | 3 | export default async (fastify) => { 4 | fastify.addSchema({ 5 | $id: 'project', 6 | type: 'object', 7 | properties: { 8 | project: { 9 | type: 'object', 10 | properties: { 11 | id: { 12 | type: 'string', 13 | format: 'uuid', 14 | }, 15 | name: { 16 | type: 'string', 17 | minLength: 1, 18 | maxLength: 255, 19 | }, 20 | time_zone: { 21 | type: 'string', 22 | minLength: 1, 23 | maxLength: 255, 24 | enum: timeZonesNames, 25 | }, 26 | created_at: { 27 | type: 'string', 28 | format: 'date-time', 29 | }, 30 | updated_at: { 31 | type: 'string', 32 | format: 'date-time', 33 | }, 34 | is_shared: { type: 'boolean' }, 35 | }, 36 | required: ['id', 'name', 'time_zone', 'created_at', 'updated_at'], 37 | if: { 38 | type: 'object', 39 | properties: { 40 | is_shared: { const: false }, 41 | }, 42 | }, 43 | then: { 44 | type: 'object', 45 | properties: { 46 | secret: { 47 | type: 'string', 48 | minLength: 1, 49 | maxLength: 255, 50 | }, 51 | shared_secret: { 52 | type: ['string', 'null'], 53 | minLength: 1, 54 | maxLength: 255, 55 | }, 56 | user: { 57 | type: 'object', 58 | properties: { 59 | email: { 60 | type: 'string', 61 | format: 'email', 62 | minLength: 1, 63 | maxLength: 255, 64 | }, 65 | }, 66 | required: ['email'], 67 | }, 68 | }, 69 | required: ['secret', 'shared_secret', 'user'], 70 | }, 71 | }, 72 | }, 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /api/routes/api/projects/current.js: -------------------------------------------------------------------------------- 1 | export default async (fastify) => { 2 | fastify.get( 3 | '/current', 4 | { 5 | preValidation: fastify.auth({ allowShared: true }), 6 | schema: { 7 | response: { 8 | 200: { 9 | $ref: 'project', 10 | }, 11 | '4xx': { 12 | $ref: 'error', 13 | }, 14 | '5xx': { 15 | $ref: 'error', 16 | }, 17 | }, 18 | }, 19 | }, 20 | async (request) => ({ project: request.currentProject }), 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /api/routes/api/projects/post.js: -------------------------------------------------------------------------------- 1 | import { timeZonesNames } from '@vvo/tzdb' 2 | 3 | import ProjectCreator from '#api/services/projects/project_creator' 4 | 5 | export default async (fastify) => { 6 | fastify.post( 7 | '/', 8 | { 9 | schema: { 10 | body: { 11 | type: 'object', 12 | properties: { 13 | project: { 14 | type: 'object', 15 | properties: { 16 | name: { 17 | type: 'string', 18 | minLength: 1, 19 | maxLength: 255, 20 | }, 21 | time_zone: { 22 | type: 'string', 23 | minLength: 1, 24 | maxLength: 255, 25 | enum: timeZonesNames, 26 | }, 27 | user: { 28 | type: 'object', 29 | properties: { 30 | email: { 31 | type: 'string', 32 | format: 'email', 33 | minLength: 1, 34 | maxLength: 255, 35 | }, 36 | }, 37 | required: ['email'], 38 | }, 39 | }, 40 | required: ['name', 'time_zone', 'user'], 41 | }, 42 | }, 43 | required: ['project'], 44 | }, 45 | response: { 46 | 201: { 47 | $ref: 'project', 48 | }, 49 | '4xx': { 50 | $ref: 'error', 51 | }, 52 | '5xx': { 53 | $ref: 'error', 54 | }, 55 | }, 56 | }, 57 | }, 58 | async (request, reply) => { 59 | const creator = new ProjectCreator({ 60 | data: request.body.project, 61 | }) 62 | 63 | const project = await creator.create() 64 | 65 | reply.statusCode = 201 66 | 67 | return { project } 68 | }, 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /api/routes/api/time_zones/get.js: -------------------------------------------------------------------------------- 1 | import { getTimeZones } from '@vvo/tzdb' 2 | 3 | export default async (fastify) => { 4 | fastify.get( 5 | '/', 6 | { 7 | schema: { 8 | response: { 9 | 200: { 10 | type: 'object', 11 | properties: { 12 | time_zones: { 13 | type: 'array', 14 | items: { 15 | type: 'object', 16 | properties: { 17 | name: { type: 'string' }, 18 | formatted: { type: 'string' }, 19 | group_names: { 20 | type: 'array', 21 | items: { type: 'string' }, 22 | }, 23 | }, 24 | required: ['name', 'formatted', 'group_names'], 25 | }, 26 | }, 27 | }, 28 | required: ['time_zones'], 29 | }, 30 | '4xx': { 31 | $ref: 'error', 32 | }, 33 | '5xx': { 34 | $ref: 'error', 35 | }, 36 | }, 37 | }, 38 | }, 39 | async () => ({ 40 | time_zones: getTimeZones().map((timeZone) => ({ 41 | name: timeZone.name, 42 | formatted: timeZone.currentTimeFormat, 43 | group_names: timeZone.group, 44 | })), 45 | }), 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /api/routes/ship/get.js: -------------------------------------------------------------------------------- 1 | import px from '#api/lib/px' 2 | import Project from '#api/models/project' 3 | import CountIncrementor from '#api/services/counts/count_incrementor' 4 | import CountRequestData from '#api/services/counts/count_request_data' 5 | import AppError from '#api/services/errors/app_error' 6 | 7 | export default async (fastify) => { 8 | await fastify.register(import('@fastify/cors')) 9 | 10 | fastify.get( 11 | '/', 12 | { 13 | attachValidation: true, 14 | schema: { 15 | querystring: { 16 | type: 'object', 17 | properties: { 18 | p: { 19 | type: 'string', 20 | format: 'uuid', 21 | }, 22 | i: { 23 | type: 'string', 24 | minLength: 1, 25 | maxLength: 255, 26 | }, 27 | }, 28 | required: ['p'], 29 | }, 30 | response: { 31 | 200: { 32 | type: 'string', 33 | contentEncoding: 'binary', 34 | contentMediaType: 'image/gif', 35 | }, 36 | }, 37 | }, 38 | }, 39 | async (request, reply) => { 40 | await reply 41 | .type('image/gif') 42 | .headers({ 43 | 'Cache-Control': 'private, max-age=0, no-cache, no-store', 44 | Expires: '-1', 45 | Pragma: 'no-cache', 46 | }) 47 | .send(px) 48 | 49 | try { 50 | if (request.validationError) { 51 | throw request.validationError 52 | } 53 | 54 | const data = new CountRequestData({ request }) 55 | 56 | const project = await Project.query().findById(data.projectID) 57 | 58 | if (!project) { 59 | throw new AppError(404, 'Project not found') 60 | } 61 | 62 | const incrementor = new CountIncrementor({ data, project }) 63 | 64 | await incrementor.increment() 65 | } catch (error) { 66 | const statusCode = error.status ?? error.statusCode ?? 500 67 | 68 | if (statusCode >= 500) { 69 | request.log.error(error) 70 | } else if (statusCode >= 400) { 71 | request.log.warn(error) 72 | } 73 | } 74 | }, 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /api/routes/web.js: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path' 2 | import { env } from 'node:process' 3 | 4 | import { webDir } from '#api/lib/paths' 5 | 6 | export default async (fastify) => { 7 | if (env.NODE_ENV !== 'production') { 8 | return 9 | } 10 | 11 | await fastify.register(import('@fastify/static'), { 12 | root: join(webDir, 'dist'), 13 | wildcard: false, 14 | setHeaders(reply) { 15 | reply.setHeader( 16 | 'Content-Security-Policy', 17 | "default-src 'none'; connect-src 'self'; img-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", 18 | ) 19 | reply.setHeader( 20 | 'Strict-Transport-Security', 21 | 'max-age=63072000; includeSubDomains; preload', 22 | ) 23 | reply.setHeader('X-Content-Type-Options', 'nosniff') 24 | reply.setHeader('X-Frame-Options', 'DENY') 25 | reply.setHeader('X-XSS-Protection', '1; mode=block') 26 | reply.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') 27 | }, 28 | }) 29 | 30 | fastify.get('/*', (request, reply) => { 31 | reply.sendFile('index.html') 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /api/services/counts/count_incrementor.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | 3 | import Count from '#api/models/count' 4 | 5 | class CountIncrementor { 6 | constructor({ data, project }) { 7 | this.data = data 8 | this.project = project 9 | 10 | this.date = DateTime.local().setZone(this.project.time_zone) 11 | } 12 | 13 | create() { 14 | return Count.query() 15 | .insert({ 16 | project_id: this.project.id, 17 | identifier: this.data.identifier, 18 | date: this.date.toSQLDate(), 19 | count: 1, 20 | }) 21 | .returning('*') 22 | } 23 | 24 | update() { 25 | return Count.query() 26 | .increment('count', 1) 27 | .where({ 28 | project_id: this.project.id, 29 | identifier: this.data.identifier, 30 | date: this.date.toSQLDate(), 31 | }) 32 | .returning('*') 33 | .first() 34 | } 35 | 36 | async increment() { 37 | let count = await this.update() 38 | 39 | if (count) { 40 | return count 41 | } 42 | 43 | count = await this.create() 44 | 45 | return count 46 | } 47 | } 48 | 49 | export default CountIncrementor 50 | -------------------------------------------------------------------------------- /api/services/counts/count_pruner.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | 3 | import Count from '#api/models/count' 4 | 5 | class CountPruner { 6 | constructor() { 7 | this.olderThan = DateTime.utc().minus({ days: 31 }) 8 | } 9 | 10 | prune() { 11 | return Count.query().delete().where('date', '<', this.olderThan.toSQLDate()) 12 | } 13 | } 14 | 15 | export default CountPruner 16 | -------------------------------------------------------------------------------- /api/services/counts/count_request_data.js: -------------------------------------------------------------------------------- 1 | class CountRequestData { 2 | constructor({ request }) { 3 | this.request = request 4 | 5 | this._identifier = undefined 6 | } 7 | 8 | get projectID() { 9 | return this.request.query.p 10 | } 11 | 12 | get identifier() { 13 | if (this._identifier !== undefined) { 14 | return this._identifier 15 | } 16 | 17 | if (this.request.query.i !== undefined) { 18 | return this.request.query.i 19 | } 20 | 21 | if (this.request.headers.referer === undefined) { 22 | return null 23 | } 24 | 25 | let url 26 | 27 | try { 28 | url = new URL(this.request.headers.referer) 29 | 30 | this._identifier = url.pathname 31 | } catch { 32 | this._identifier = null 33 | } 34 | 35 | return this._identifier 36 | } 37 | } 38 | 39 | export default CountRequestData 40 | -------------------------------------------------------------------------------- /api/services/cron_job_scheduler.js: -------------------------------------------------------------------------------- 1 | import { CronJob } from 'cron' 2 | 3 | import CountPruner from '#api/services/counts/count_pruner' 4 | 5 | const cronJobs = [ 6 | new CronJob('0 0 8 * * *', async () => { 7 | const pruner = new CountPruner() 8 | 9 | await pruner.prune() 10 | }), 11 | ] 12 | 13 | function cronJobsScheduler() { 14 | for (const cronJob of cronJobs) { 15 | cronJob.start() 16 | } 17 | 18 | return cronJobs 19 | } 20 | 21 | export default cronJobsScheduler 22 | -------------------------------------------------------------------------------- /api/services/errors/app_error.js: -------------------------------------------------------------------------------- 1 | import BaseError from '#api/services/errors/base_error' 2 | 3 | class AppError extends BaseError { 4 | constructor(status, message) { 5 | super(message) 6 | 7 | this.status = status 8 | } 9 | } 10 | 11 | export default AppError 12 | -------------------------------------------------------------------------------- /api/services/errors/base_error.js: -------------------------------------------------------------------------------- 1 | class BaseError extends Error { 2 | constructor(message) { 3 | super(message) 4 | 5 | Error.captureStackTrace(this, this.constructor) 6 | 7 | this.name = this.constructor.name 8 | } 9 | } 10 | 11 | export default BaseError 12 | -------------------------------------------------------------------------------- /api/services/errors/error_handler.js: -------------------------------------------------------------------------------- 1 | import errorMap from '#api/services/errors/error_map' 2 | 3 | const errorSchema = { 4 | $id: 'error', 5 | type: 'object', 6 | properties: { 7 | error: { 8 | type: 'object', 9 | properties: { 10 | status: { 11 | type: 'integer', 12 | }, 13 | message: { 14 | type: 'string', 15 | }, 16 | }, 17 | required: ['status', 'message'], 18 | }, 19 | }, 20 | required: ['error'], 21 | } 22 | 23 | async function errorHandler(error, request, reply) { 24 | if (errorMap.has(error.constructor)) { 25 | error = errorMap.get(error.constructor)(error) 26 | } 27 | 28 | reply.statusCode = error.status ?? error.statusCode ?? reply.statusCode 29 | 30 | if (reply.statusCode >= 500) { 31 | request.log.error({ err: error, res: reply }, error.message) 32 | } else if (reply.statusCode >= 400) { 33 | request.log.warn({ err: error, res: reply }, error.message) 34 | } 35 | 36 | return { 37 | error: { 38 | status: reply.statusCode, 39 | message: error.message, 40 | }, 41 | } 42 | } 43 | 44 | export default errorHandler 45 | export { errorSchema } 46 | -------------------------------------------------------------------------------- /api/services/errors/error_map.js: -------------------------------------------------------------------------------- 1 | import { Model, NotFoundError } from 'objection' 2 | 3 | import AppError from '#api/services/errors/app_error' 4 | 5 | const errorMap = new Map([ 6 | [ 7 | NotFoundError, 8 | () => new AppError(404, 'The requested resource was not found'), 9 | ], 10 | [ 11 | Model.ValidationError, 12 | (error) => { 13 | const [[fieldName, [fieldError]]] = Object.entries(error.data) 14 | 15 | return new AppError(422, `${fieldName}: ${fieldError.message}`) 16 | }, 17 | ], 18 | ]) 19 | 20 | export default errorMap 21 | -------------------------------------------------------------------------------- /api/services/projects/project_creator.js: -------------------------------------------------------------------------------- 1 | import pick from '#api/lib/pick' 2 | import Project from '#api/models/project' 3 | import projectSecretGenerator from '#api/services/projects/project_secret_generator' 4 | import UserCreator from '#api/services/users/user_creator' 5 | 6 | class ProjectCreator { 7 | constructor({ data, transaction = null }) { 8 | this.data = pick(data, ['name', 'time_zone']) 9 | this.transaction = transaction 10 | 11 | this.userData = pick(data.user, ['email']) 12 | } 13 | 14 | createUser() { 15 | const creator = new UserCreator({ 16 | data: this.userData, 17 | transaction: this.transaction, 18 | }) 19 | 20 | return creator.create() 21 | } 22 | 23 | createWithTransaction() { 24 | return Project.transaction(async (transaction) => { 25 | this.transaction = transaction 26 | 27 | const create = await this.create() 28 | 29 | this.transaction = null 30 | 31 | return create 32 | }) 33 | } 34 | 35 | async create() { 36 | if (!this.transaction) { 37 | return this.createWithTransaction() 38 | } 39 | 40 | const user = await this.createUser() 41 | 42 | const project = await Project.query(this.transaction) 43 | .insert({ 44 | ...this.data, 45 | user_id: user.id, 46 | secret: projectSecretGenerator(), 47 | }) 48 | .returning('*') 49 | 50 | project.$setRelated('user', user) 51 | 52 | return project 53 | } 54 | } 55 | 56 | export default ProjectCreator 57 | -------------------------------------------------------------------------------- /api/services/projects/project_secret_generator.js: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'node:crypto' 2 | 3 | function projectSecretGenerator() { 4 | return randomBytes(48).toString('base64url') 5 | } 6 | 7 | export default projectSecretGenerator 8 | -------------------------------------------------------------------------------- /api/services/projects/project_updater.js: -------------------------------------------------------------------------------- 1 | import pick from '#api/lib/pick' 2 | import Project from '#api/models/project' 3 | import projectSecretGenerator from '#api/services/projects/project_secret_generator' 4 | import UserUpdater from '#api/services/users/user_updater' 5 | 6 | class ProjectUpdater { 7 | constructor({ data, project, transaction = null }) { 8 | this.data = pick(data, ['name', 'time_zone']) 9 | this.project = project 10 | this.transaction = transaction 11 | 12 | this.userData = pick(data.user, ['email']) 13 | this.allowShared = data.allow_shared 14 | } 15 | 16 | updateUser() { 17 | if (!this.project.user || !this.userData) { 18 | return false 19 | } 20 | 21 | const updater = new UserUpdater({ 22 | data: this.userData, 23 | transaction: this.transaction, 24 | user: this.project.user, 25 | }) 26 | 27 | return updater.update() 28 | } 29 | 30 | updateWithTransaction() { 31 | return Project.transaction(async (transaction) => { 32 | this.transaction = transaction 33 | 34 | const update = await this.update() 35 | 36 | this.transaction = null 37 | 38 | return update 39 | }) 40 | } 41 | 42 | async update() { 43 | if (!this.transaction) { 44 | return this.updateWithTransaction() 45 | } 46 | 47 | const data = { ...this.data } 48 | 49 | if (this.allowShared === true && !this.project.shared_secret) { 50 | data.shared_secret = projectSecretGenerator() 51 | } else if (this.allowShared === false) { 52 | data.shared_secret = null 53 | } 54 | 55 | await this.project.$query(this.transaction).patch(data) 56 | 57 | await this.updateUser() 58 | 59 | return this.project 60 | } 61 | } 62 | 63 | export default ProjectUpdater 64 | -------------------------------------------------------------------------------- /api/services/users/user_creator.js: -------------------------------------------------------------------------------- 1 | import pick from '#api/lib/pick' 2 | import User from '#api/models/user' 3 | 4 | class UserCreator { 5 | constructor({ data, transaction = null }) { 6 | this.data = pick(data, ['email']) 7 | this.transaction = transaction 8 | } 9 | 10 | createWithTransaction() { 11 | return User.transaction(async (transaction) => { 12 | this.transaction = transaction 13 | 14 | const create = await this.create() 15 | 16 | this.transaction = null 17 | 18 | return create 19 | }) 20 | } 21 | 22 | create() { 23 | if (!this.transaction) { 24 | return this.createWithTransaction() 25 | } 26 | 27 | return User.findOneOrInsert(this.data, null, this.transaction) 28 | } 29 | } 30 | 31 | export default UserCreator 32 | -------------------------------------------------------------------------------- /api/services/users/user_updater.js: -------------------------------------------------------------------------------- 1 | import pick from '#api/lib/pick' 2 | import User from '#api/models/user' 3 | 4 | class UserUpdater { 5 | constructor({ data, transaction = null, user }) { 6 | this.data = pick(data, ['email']) 7 | this.transaction = transaction 8 | this.user = user 9 | } 10 | 11 | updateWithTransaction() { 12 | return User.transaction(async (transaction) => { 13 | this.transaction = transaction 14 | 15 | const update = await this.update() 16 | 17 | this.transaction = null 18 | 19 | return update 20 | }) 21 | } 22 | 23 | async update() { 24 | if (!this.transaction) { 25 | return this.updateWithTransaction() 26 | } 27 | 28 | await this.user.$query(this.transaction).patch({ 29 | ...this.data, 30 | }) 31 | 32 | return this.user 33 | } 34 | } 35 | 36 | export default UserUpdater 37 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | name: piratepx 2 | 3 | services: 4 | postgres: 5 | image: postgres:16-alpine 6 | ports: 7 | - '5432:5432' 8 | environment: 9 | POSTGRES_DB: piratepx_development 10 | POSTGRES_PASSWORD: password 11 | volumes: 12 | - postgres:/var/lib/postgresql/data 13 | 14 | volumes: 15 | postgres: 16 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import prettier from 'eslint-config-prettier' 3 | import node from 'eslint-plugin-n' 4 | import globals from 'globals' 5 | 6 | export default [ 7 | { 8 | languageOptions: { 9 | globals: { ...globals.node }, 10 | }, 11 | }, 12 | { 13 | ignores: ['web'], 14 | }, 15 | js.configs.recommended, 16 | node.configs['flat/recommended'], 17 | prettier, 18 | ] 19 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2020", 4 | "baseUrl": ".", 5 | "paths": { 6 | "#api/*": ["./api/*"] 7 | } 8 | }, 9 | "include": ["./api/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "piratepx-app", 3 | "version": "0.4.2", 4 | "type": "module", 5 | "private": true, 6 | "description": "Just a little analytics insight for your personal or indie project", 7 | "homepage": "https://app.piratepx.com", 8 | "bugs": "https://github.com/piratepx/app/issues", 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/piratepx/app.git" 13 | }, 14 | "scripts": { 15 | "build": "cd web && npm install && npm run build", 16 | "dev": "npm run dev:api & npm run dev:web", 17 | "dev:api": "fastify start --watch --ignore-watch=web --log-level=info --pretty-logs ./api/app.js", 18 | "dev:db:create": "docker compose exec postgres createdb -U postgres piratepx_development", 19 | "dev:db:drop": "docker compose exec postgres dropdb -U postgres piratepx_development", 20 | "dev:db:psql": "docker compose exec postgres psql -U postgres", 21 | "dev:web": "cd web && npm run dev", 22 | "knex": "node -r dotenv/config ./node_modules/.bin/knex --knexfile ./api/db/config.js", 23 | "lint": "npm run lint:format && npm run lint:quality", 24 | "lint:format": "prettier --check .", 25 | "lint:format:fix": "prettier --write .", 26 | "lint:quality": "eslint .", 27 | "lint:quality:fix": "eslint --fix .", 28 | "release": "release-it --only-version", 29 | "start": "fastify start --log-level=info ./api/app.js" 30 | }, 31 | "dependencies": { 32 | "@fastify/autoload": "^5.10.0", 33 | "@fastify/bearer-auth": "^9.4.0", 34 | "@fastify/cors": "^9.0.1", 35 | "@fastify/static": "^7.0.4", 36 | "@vvo/tzdb": "^6.145.0", 37 | "cron": "^3.1.7", 38 | "dotenv": "^16.4.5", 39 | "fastify": "^4.28.1", 40 | "fastify-cli": "^6.3.0", 41 | "knex": "^3.1.0", 42 | "luxon": "^3.5.0", 43 | "objection": "^3.1.4", 44 | "pg": "^8.13.0" 45 | }, 46 | "devDependencies": { 47 | "@eslint/js": "^9.11.1", 48 | "@release-it/bumper": "^6.0.1", 49 | "eslint": "^9.11.1", 50 | "eslint-config-prettier": "^9.1.0", 51 | "eslint-plugin-n": "^17.10.3", 52 | "globals": "^15.9.0", 53 | "prettier": "3.3.3", 54 | "release-it": "^17.6.0" 55 | }, 56 | "engines": { 57 | "node": "22.x", 58 | "npm": "10.x" 59 | }, 60 | "imports": { 61 | "#api/*": "./api/*.js" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /web/.env.development: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | 3 | VITE_API_URL=http://localhost:8080/api 4 | VITE_PIRATEPX_PROJECT_ID= 5 | VITE_PIRATEPX_URL= 6 | -------------------------------------------------------------------------------- /web/.env.production: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | 3 | VITE_API_URL=https://app.piratepx.com/api 4 | VITE_PIRATEPX_PROJECT_ID=7aeb3ca2-ca76-49ec-ad27-24f0d380a545 5 | VITE_PIRATEPX_URL=https://app.piratepx.com/ship 6 | -------------------------------------------------------------------------------- /web/.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | "es2020": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:vue/vue3-recommended", 11 | "prettier", 12 | "prettier/vue" 13 | ], 14 | "rules": {} 15 | } 16 | -------------------------------------------------------------------------------- /web/.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | /node_modules 3 | 4 | # Config 5 | .env.local 6 | .env.*.local 7 | 8 | # Logs 9 | npm-debug.log* 10 | 11 | # Build 12 | /dist 13 | -------------------------------------------------------------------------------- /web/.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /web/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "always", 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /web/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 piratepx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # 🏴‍☠️px Web 2 | 3 | This README pertains to the frontend of the piratepx app, which is a single-page 4 | app built with [Vue.js](https://vuejs.org/) and 5 | [Tailwind CSS](https://tailwindcss.com/). 6 | 7 | See the [README](../README.md) in the root of the repository for details on the 8 | backend and setting up the full app for development. 9 | 10 | ## Development 11 | 12 | [Vite](https://github.com/vitejs/vite) is used to develop and build the app. See 13 | their [docs](https://github.com/vitejs/vite#readme) for specifics not covered 14 | here. 15 | 16 | ### Prerequisites 17 | 18 | The only prerequisite is a compatible version of [Node.js](https://nodejs.org/) 19 | (see `engines.node` in [`package.json`](package.json)). 20 | [nvm](https://github.com/nvm-sh/nvm) is the recommended installation method: 21 | 22 | ```bash 23 | $ nvm install 24 | ``` 25 | 26 | ### Dependencies 27 | 28 | Install dependencies with npm: 29 | 30 | ```bash 31 | $ npm install 32 | ``` 33 | 34 | ### Start 35 | 36 | Start the development server: 37 | 38 | ```bash 39 | $ npm run dev 40 | ``` 41 | 42 | ### Code Style & Linting 43 | 44 | [Prettier](https://prettier.com/) is setup to enforce a consistent code style. 45 | It's highly recommended to 46 | [add an integration to your editor](https://prettier.io/docs/en/editors.html) 47 | that automatically formats on save. 48 | 49 | [ESLint](https://eslint.org/) is setup with the 50 | ["recommended" rules](https://eslint.org/docs/rules/) to enforce a level of code 51 | quality. It's also highly recommended to 52 | [add an integration to your editor](https://eslint.org/docs/user-guide/integrations#editors) 53 | that automatically formats on save. 54 | 55 | To run via the command line: 56 | 57 | ```bash 58 | $ npm run lint 59 | ``` 60 | 61 | ## Releasing 62 | 63 | The frontend is built and released with the backend for convenience. See the 64 | [README](../README.md) in the root of the repository for further details. 65 | 66 | ### Production Build 67 | 68 | The production-optimized single-page app can be built by running: 69 | 70 | ```bash 71 | $ npm run build 72 | ``` 73 | 74 | This creates a `dist` directory with the files. 75 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | piratepx 12 | 13 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "piratepx-web", 3 | "version": "0.4.2", 4 | "private": true, 5 | "description": "Just a little analytics insight for your personal or indie project", 6 | "homepage": "https://app.piratepx.com", 7 | "bugs": "https://github.com/piratepx/app/issues", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/piratepx/app.git" 12 | }, 13 | "scripts": { 14 | "build": "vite build", 15 | "dev": "vite", 16 | "lint": "npm run lint:format && npm run lint:quality", 17 | "lint:format": "prettier --check .", 18 | "lint:format:fix": "prettier --write .", 19 | "lint:quality": "eslint --ext .js,.vue .", 20 | "lint:quality:fix": "eslint --ext .js,.vue --fix ." 21 | }, 22 | "dependencies": { 23 | "tailwindcss": "^1.9.6", 24 | "vue": "^3.0.4", 25 | "vue-router": "^4.0.1", 26 | "vuex": "^4.0.0-rc.2" 27 | }, 28 | "devDependencies": { 29 | "@vue/compiler-sfc": "^3.0.4", 30 | "autoprefixer": "^9.8.6", 31 | "eslint": "^7.15.0", 32 | "eslint-config-prettier": "^7.0.0", 33 | "eslint-plugin-vue": "^7.2.0", 34 | "prettier": "2.2.1", 35 | "vite": "^1.0.0-rc.13" 36 | }, 37 | "engines": { 38 | "node": "22.x", 39 | "npm": "10.x" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('tailwindcss'), require('autoprefixer')], 3 | } 4 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piratepx/app/648f7c146eb35a64301a4bb11b566cfe7f2f29a6/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /web/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/components/base/BaseAlert.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 33 | -------------------------------------------------------------------------------- /web/src/components/base/BaseButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 62 | 63 | 83 | -------------------------------------------------------------------------------- /web/src/components/base/BaseCenter.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /web/src/components/base/BaseIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 36 | -------------------------------------------------------------------------------- /web/src/components/base/BaseIconChevronBottom.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 26 | -------------------------------------------------------------------------------- /web/src/components/base/BaseIconChevronLeft.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 26 | -------------------------------------------------------------------------------- /web/src/components/base/BaseIconSettings.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 29 | -------------------------------------------------------------------------------- /web/src/components/base/BaseSelect.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 40 | 41 | 47 | -------------------------------------------------------------------------------- /web/src/components/base/BaseTextInput.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 20 | -------------------------------------------------------------------------------- /web/src/components/project/ProjectForm.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 138 | -------------------------------------------------------------------------------- /web/src/components/project/ProjectIndex.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 63 | -------------------------------------------------------------------------------- /web/src/components/project/ProjectSettings.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 114 | -------------------------------------------------------------------------------- /web/src/components/project/dashboard/ProjectDashboard.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 98 | -------------------------------------------------------------------------------- /web/src/components/project/dashboard/ProjectDashboardHeader.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 75 | -------------------------------------------------------------------------------- /web/src/components/project/dashboard/ProjectDashboardRecord.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 76 | -------------------------------------------------------------------------------- /web/src/components/project/dashboard/ProjectDashboardRecordCount.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 66 | -------------------------------------------------------------------------------- /web/src/components/project/dashboard/ProjectDashboardScrollable.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 62 | -------------------------------------------------------------------------------- /web/src/components/project/dashboard/ProjectDashboardWelcome.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 50 | -------------------------------------------------------------------------------- /web/src/components/signup/SignupIndex.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 47 | -------------------------------------------------------------------------------- /web/src/components/the/TheLayout.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 36 | -------------------------------------------------------------------------------- /web/src/components/the/ThePiratepx.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 52 | -------------------------------------------------------------------------------- /web/src/lib/to_iso_date_string.js: -------------------------------------------------------------------------------- 1 | export function dateTimeFormat(timeZone) { 2 | return new Intl.DateTimeFormat('en-US', { 3 | timeZone, 4 | year: 'numeric', 5 | month: '2-digit', 6 | day: '2-digit', 7 | }) 8 | } 9 | 10 | export default function toISODateString({ 11 | date, 12 | format = null, 13 | timeZone = null, 14 | }) { 15 | const formatter = format || dateTimeFormat(timeZone) 16 | 17 | const parts = formatter.formatToParts(date) 18 | const year = parts.find((part) => part.type === 'year').value 19 | const month = parts.find((part) => part.type === 'month').value 20 | const day = parts.find((part) => part.type === 'day').value 21 | 22 | return `${year}-${month}-${day}` 23 | } 24 | -------------------------------------------------------------------------------- /web/src/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | @tailwind components; 4 | 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /web/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | 3 | import App from '/@/App.vue' 4 | import pageTitle from '/@/plugins/page_title' 5 | import router from '/@/router' 6 | import store from '/@/store' 7 | 8 | import '/@/main.css' 9 | 10 | const app = createApp(App) 11 | 12 | app.use(pageTitle) 13 | app.use(router) 14 | app.use(store) 15 | 16 | app.mount('#app') 17 | -------------------------------------------------------------------------------- /web/src/plugins/page_title.js: -------------------------------------------------------------------------------- 1 | import { computed, watch } from 'vue' 2 | 3 | export default (app, { base = document.title, separator = ' – ' } = {}) => { 4 | function setPageTitle(pageTitle) { 5 | const parts = Array.isArray(pageTitle) 6 | ? [...pageTitle, base] 7 | : [pageTitle, base] 8 | 9 | document.title = parts.filter(Boolean).join(separator) 10 | } 11 | 12 | app.mixin({ 13 | created() { 14 | let pageTitle = this.$options.pageTitle 15 | 16 | if (!pageTitle) { 17 | return 18 | } 19 | 20 | if (typeof pageTitle !== 'function') { 21 | return setPageTitle(pageTitle) 22 | } 23 | 24 | pageTitle = computed(pageTitle.bind(this)) 25 | 26 | watch(pageTitle, setPageTitle) 27 | 28 | setPageTitle(pageTitle.value) 29 | }, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /web/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | 3 | import ProjectDashboardView from '/@/views/project/ProjectDashboardView.vue' 4 | import ProjectIndexView from '/@/views/project/ProjectIndexView.vue' 5 | import ProjectSettingsView from '/@/views/project/ProjectSettingsView.vue' 6 | import SignupIndexView from '/@/views/signup/SignupIndexView.vue' 7 | 8 | const router = createRouter({ 9 | history: createWebHistory(), 10 | routes: [ 11 | { 12 | path: '/', 13 | redirect: '/signup', 14 | }, 15 | { 16 | path: '/signup', 17 | name: 'signup', 18 | component: SignupIndexView, 19 | }, 20 | { 21 | path: '/shared/:secret', 22 | component: ProjectIndexView, 23 | props: (route) => ({ 24 | isShared: true, 25 | secret: route.params.secret, 26 | }), 27 | children: [ 28 | { 29 | path: 'dashboard', 30 | name: 'shared/dashboard', 31 | alias: '', 32 | component: ProjectDashboardView, 33 | }, 34 | ], 35 | }, 36 | { 37 | path: '/:secret', 38 | component: ProjectIndexView, 39 | props: (route) => ({ 40 | isShared: false, 41 | secret: route.params.secret, 42 | }), 43 | children: [ 44 | { 45 | path: 'dashboard', 46 | name: 'dashboard', 47 | alias: '', 48 | component: ProjectDashboardView, 49 | }, 50 | { 51 | path: 'settings', 52 | name: 'settings', 53 | component: ProjectSettingsView, 54 | }, 55 | ], 56 | }, 57 | ], 58 | }) 59 | 60 | export default router 61 | -------------------------------------------------------------------------------- /web/src/services/api.js: -------------------------------------------------------------------------------- 1 | class API { 2 | constructor(config = {}) { 3 | this.config = config 4 | } 5 | 6 | static set defaultConfig(defaultConfig) { 7 | this._defaultConfig = { 8 | ...this._defaultConfig, 9 | ...defaultConfig, 10 | } 11 | } 12 | 13 | static get defaultConfig() { 14 | return this._defaultConfig 15 | } 16 | 17 | set config(config) { 18 | this._config = { 19 | ...this.constructor.defaultConfig, 20 | ...this._config, 21 | ...config, 22 | } 23 | } 24 | 25 | get config() { 26 | return this._config 27 | } 28 | 29 | buildAuthorizationHeader() { 30 | let secret = this.config.secret 31 | 32 | if (!secret) { 33 | return null 34 | } 35 | 36 | if (this.config.isSharedSecret) { 37 | secret = `shared/${secret}` 38 | } 39 | 40 | return { Authorization: `Bearer ${secret}` } 41 | } 42 | 43 | async request(path, { headers, body, ...options } = {}) { 44 | const response = await fetch(`${this.config.url}${path}`, { 45 | method: 'GET', 46 | headers: { 47 | 'Content-Type': 'application/json', 48 | ...this.buildAuthorizationHeader(), 49 | ...headers, 50 | }, 51 | body: body ? JSON.stringify(body) : null, 52 | ...options, 53 | }) 54 | 55 | return response.json() 56 | } 57 | } 58 | 59 | API.defaultConfig = { 60 | isSharedSecret: false, 61 | secret: null, 62 | url: import.meta.env.VITE_API_URL, 63 | } 64 | 65 | export default API 66 | -------------------------------------------------------------------------------- /web/src/services/format_number.js: -------------------------------------------------------------------------------- 1 | const formatter = new Intl.NumberFormat() 2 | 3 | export function formatNumberLong(number) { 4 | return formatter.format(number) 5 | } 6 | 7 | export function formatNumberShort(number) { 8 | if (number <= 999) { 9 | return number 10 | } else if (number <= 9999) { 11 | return `${(number / 1000).toFixed(1)}k` 12 | } else if (number <= 999999) { 13 | return `${Math.round(number / 1000)}k` 14 | } else if (number <= 999999999) { 15 | return `${Math.round(number / 1000000)}m` 16 | } else if (number <= 999999999999) { 17 | return `${Math.round(number / 1000000000)}b` 18 | } else if (number <= 999999999999999) { 19 | return `${Math.round(number / 1000000000000)}t` 20 | } else { 21 | return '∞' 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'vuex' 2 | 3 | import counts from '/@/store/modules/counts' 4 | import projects from '/@/store/modules/projects' 5 | import signup from '/@/store/modules/signup' 6 | import timeZones from '/@/store/modules/time_zones' 7 | 8 | export default createStore({ 9 | modules: { 10 | counts, 11 | projects, 12 | signup, 13 | timeZones, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /web/src/store/modules/counts.js: -------------------------------------------------------------------------------- 1 | import API from '/@/services/api' 2 | import toISODateString from '/@/lib/to_iso_date_string' 3 | 4 | const initialState = () => ({ 5 | status: null, 6 | records: null, 7 | error: null, 8 | }) 9 | 10 | export const STATUS_FETCHING = 'FETCHING' 11 | export const STATUS_SUCCESS = 'SUCCESS' 12 | export const STATUS_ERROR = 'ERROR' 13 | 14 | export default { 15 | namespaced: true, 16 | state: initialState(), 17 | getters: { 18 | recordsGrouped(state) { 19 | if (!state.records) { 20 | return null 21 | } 22 | 23 | return state.records.reduce((map, record) => { 24 | if (map.has(record.identifier)) { 25 | map.get(record.identifier).set(record.date, record) 26 | } else { 27 | map.set(record.identifier, new Map([[record.date, record]])) 28 | } 29 | 30 | return map 31 | }, new Map()) 32 | }, 33 | recordsGroupedAndSorted(state, getters, rootState) { 34 | if (!state.records) { 35 | return null 36 | } 37 | 38 | const today = toISODateString({ 39 | date: new Date(), 40 | timeZone: rootState.projects.current.record.time_zone, 41 | }) 42 | const recordsGrouped = Array.from(getters.recordsGrouped.entries()) 43 | 44 | return new Map( 45 | recordsGrouped.sort(([, a], [, b]) => { 46 | const aCount = a.has(today) ? a.get(today).count : 0 47 | const bCount = b.has(today) ? b.get(today).count : 0 48 | 49 | return bCount - aCount 50 | }) 51 | ) 52 | }, 53 | maxCount(state) { 54 | if (!state.records) { 55 | return null 56 | } 57 | 58 | return state.records.reduce( 59 | (maxCount, record) => 60 | record.count > maxCount ? record.count : maxCount, 61 | 0 62 | ) 63 | }, 64 | }, 65 | mutations: { 66 | set(state, { records }) { 67 | state.records = records 68 | state.status = STATUS_SUCCESS 69 | state.error = null 70 | }, 71 | setError(state, { error }) { 72 | state.error = error 73 | state.status = STATUS_ERROR 74 | }, 75 | setStatus(state, { status }) { 76 | state.status = status 77 | }, 78 | reset(state) { 79 | Object.assign(state, initialState()) 80 | }, 81 | }, 82 | actions: { 83 | async fetch({ commit, state }) { 84 | if (state.status === STATUS_FETCHING) { 85 | return false 86 | } 87 | 88 | commit('setStatus', { status: STATUS_FETCHING }) 89 | 90 | const { counts: records, error } = await new API().request('/counts') 91 | 92 | if (error) { 93 | commit('setError', { error }) 94 | } else { 95 | commit('set', { records }) 96 | } 97 | 98 | return records 99 | }, 100 | }, 101 | } 102 | -------------------------------------------------------------------------------- /web/src/store/modules/projects/current.js: -------------------------------------------------------------------------------- 1 | import API from '/@/services/api' 2 | 3 | const initialState = () => ({ 4 | status: null, 5 | record: null, 6 | error: null, 7 | }) 8 | 9 | export const STATUS_FETCHING = 'FETCHING' 10 | export const STATUS_SAVING = 'SAVING' 11 | export const STATUS_SUCCESS = 'SUCCESS' 12 | export const STATUS_ERROR = 'ERROR' 13 | 14 | export default { 15 | namespaced: true, 16 | state: initialState(), 17 | mutations: { 18 | set(state, { record }) { 19 | state.record = record 20 | state.status = STATUS_SUCCESS 21 | state.error = null 22 | }, 23 | update(state, { record }) { 24 | state.record = { 25 | ...state.record, 26 | ...record, 27 | user: { 28 | ...state.record.user, 29 | ...record.user, 30 | }, 31 | } 32 | }, 33 | setError(state, { error }) { 34 | state.error = error 35 | state.status = STATUS_ERROR 36 | }, 37 | setStatus(state, { status }) { 38 | state.status = status 39 | }, 40 | reset(state) { 41 | Object.assign(state, initialState()) 42 | }, 43 | }, 44 | actions: { 45 | async fetch({ commit, state }) { 46 | if (state.status === STATUS_FETCHING) { 47 | return false 48 | } 49 | 50 | commit('setStatus', { status: STATUS_FETCHING }) 51 | 52 | const { project: record, error } = await new API().request( 53 | '/projects/current' 54 | ) 55 | 56 | if (error) { 57 | commit('setError', { error }) 58 | } else { 59 | commit('set', { record }) 60 | } 61 | 62 | return record 63 | }, 64 | async save({ commit, state }) { 65 | if (!state.record || state.status === STATUS_SAVING) { 66 | return false 67 | } 68 | 69 | commit('setStatus', { status: STATUS_SAVING }) 70 | 71 | const { project: record, error } = await new API().request( 72 | `/projects/${state.record.id}`, 73 | { 74 | method: 'PUT', 75 | body: { project: state.record }, 76 | } 77 | ) 78 | 79 | if (error) { 80 | commit('setError', { error }) 81 | } else { 82 | commit('set', { record }) 83 | } 84 | 85 | return record 86 | }, 87 | async toggleShared({ commit, state }) { 88 | if (!state.record || state.status === STATUS_SAVING) { 89 | return false 90 | } 91 | 92 | commit('setStatus', { status: STATUS_SAVING }) 93 | 94 | const { project: record, error } = await new API().request( 95 | `/projects/${state.record.id}`, 96 | { 97 | method: 'PUT', 98 | body: { 99 | project: { 100 | allow_shared: !state.record.shared_secret, 101 | }, 102 | }, 103 | } 104 | ) 105 | 106 | if (error) { 107 | commit('setError', { error }) 108 | } else { 109 | commit('set', { 110 | record: { 111 | ...state.record, 112 | shared_secret: record.shared_secret, 113 | updated_at: record.updated_at, 114 | }, 115 | }) 116 | } 117 | 118 | return record 119 | }, 120 | }, 121 | } 122 | -------------------------------------------------------------------------------- /web/src/store/modules/projects/index.js: -------------------------------------------------------------------------------- 1 | import current from '/@/store/modules/projects/current' 2 | 3 | export default { 4 | namespaced: true, 5 | modules: { 6 | current, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /web/src/store/modules/signup.js: -------------------------------------------------------------------------------- 1 | import API from '/@/services/api' 2 | 3 | const initialState = () => ({ 4 | status: null, 5 | record: { 6 | name: null, 7 | time_zone: null, 8 | user: { 9 | email: null, 10 | }, 11 | }, 12 | error: null, 13 | }) 14 | 15 | export const STATUS_SAVING = 'SAVING' 16 | export const STATUS_SUCCESS = 'SUCCESS' 17 | export const STATUS_ERROR = 'ERROR' 18 | 19 | export default { 20 | namespaced: true, 21 | state: initialState(), 22 | mutations: { 23 | set(state, { record }) { 24 | state.record = record 25 | state.status = STATUS_SUCCESS 26 | state.error = null 27 | }, 28 | update(state, { record }) { 29 | state.record = { 30 | ...state.record, 31 | ...record, 32 | user: { 33 | ...state.record.user, 34 | ...record.user, 35 | }, 36 | } 37 | }, 38 | setError(state, { error }) { 39 | state.error = error 40 | state.status = STATUS_ERROR 41 | }, 42 | setStatus(state, { status }) { 43 | state.status = status 44 | }, 45 | reset(state) { 46 | Object.assign(state, initialState()) 47 | }, 48 | }, 49 | actions: { 50 | async save({ commit, state }) { 51 | if (state.status === STATUS_SAVING) { 52 | return false 53 | } 54 | 55 | commit('setStatus', { status: STATUS_SAVING }) 56 | 57 | const { project: record, error } = await new API().request('/projects', { 58 | method: 'POST', 59 | body: { project: state.record }, 60 | }) 61 | 62 | if (error) { 63 | commit('setError', { error }) 64 | } else { 65 | commit('set', { record }) 66 | } 67 | 68 | return record 69 | }, 70 | }, 71 | } 72 | -------------------------------------------------------------------------------- /web/src/store/modules/time_zones.js: -------------------------------------------------------------------------------- 1 | import API from '/@/services/api' 2 | 3 | const initialState = () => ({ 4 | status: null, 5 | records: null, 6 | error: null, 7 | }) 8 | 9 | export const STATUS_FETCHING = 'FETCHING' 10 | export const STATUS_SUCCESS = 'SUCCESS' 11 | export const STATUS_ERROR = 'ERROR' 12 | 13 | export default { 14 | namespaced: true, 15 | state: initialState(), 16 | mutations: { 17 | set(state, { records }) { 18 | state.records = records 19 | state.status = STATUS_SUCCESS 20 | state.error = null 21 | }, 22 | setError(state, { error }) { 23 | state.error = error 24 | state.status = STATUS_ERROR 25 | }, 26 | setStatus(state, { status }) { 27 | state.status = status 28 | }, 29 | reset(state) { 30 | Object.assign(state, initialState()) 31 | }, 32 | }, 33 | actions: { 34 | async fetch({ commit, state }) { 35 | if (state.status === STATUS_FETCHING) { 36 | return false 37 | } 38 | 39 | commit('setStatus', { status: STATUS_FETCHING }) 40 | 41 | const { time_zones: records, error } = await new API().request( 42 | '/time_zones' 43 | ) 44 | 45 | if (error) { 46 | commit('setError', { error }) 47 | } else { 48 | commit('set', { records }) 49 | } 50 | 51 | return records 52 | }, 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /web/src/views/project/ProjectDashboardView.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 62 | -------------------------------------------------------------------------------- /web/src/views/project/ProjectIndexView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 44 | -------------------------------------------------------------------------------- /web/src/views/project/ProjectSettingsView.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 44 | -------------------------------------------------------------------------------- /web/src/views/signup/SignupIndexView.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 63 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./index.html', './src/**/*.vue'], 3 | theme: { 4 | extend: { 5 | borderColor: (theme) => ({ 6 | default: theme('colors.gray.400'), 7 | }), 8 | colors: { 9 | white: '#f1f2f4', 10 | gray: { 11 | 100: '#d5d8dd', 12 | 200: '#b9bec6', 13 | 300: '#9da4af', 14 | 400: '#818a98', 15 | 500: '#67707e', 16 | 600: '#505762', 17 | 700: '#393e46', 18 | 800: '#22252a', 19 | 900: '#0b0c0e', 20 | }, 21 | }, 22 | }, 23 | }, 24 | variants: {}, 25 | plugins: [], 26 | future: { 27 | removeDeprecatedGapUtilities: true, 28 | purgeLayersByDefault: true, 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /web/vite.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | alias: { 5 | '/@/': path.resolve(__dirname, 'src'), 6 | }, 7 | port: 8080, 8 | proxy: { 9 | '/api': 'http://localhost:3000', 10 | }, 11 | } 12 | --------------------------------------------------------------------------------