diff --git a/.do/demploy.template.yaml b/.do/demploy.template.yaml new file mode 100644 index 0000000..ed38818 --- /dev/null +++ b/.do/demploy.template.yaml @@ -0,0 +1,43 @@ +spec: + name: loggit + envs: + - key: BASE_URL + scope: RUN_AND_BUILD_TIME + value: ${app.PUBLIC_URL} + services: + - name: app + dockerfile_path: Dockerfile + git: + branch: main + http_port: 8000 + instance_count: 1 + instance_size_slug: basic-xs + routes: + - path: / + health_check: + http_path: / + source_dir: / + envs: + - key: POSTGRESQL_HOST + scope: RUN_AND_BUILD_TIME + value: ${db.HOSTNAME} + - key: POSTGRESQL_USER + scope: RUN_AND_BUILD_TIME + value: ${db.USERNAME} + - key: POSTGRESQL_PASSWORD + scope: RUN_AND_BUILD_TIME + value: ${db.PASSWORD} + - key: POSTGRESQL_DBNAME + scope: RUN_AND_BUILD_TIME + value: ${db.DATABASE} + - key: POSTGRESQL_PORT + scope: RUN_AND_BUILD_TIME + value: ${db.PORT} + - key: POSTGRESQL_CAFILE + scope: RUN_AND_BUILD_TIME + value: "" + databases: + - name: db + engine: PG + production: false + version: "15" diff --git a/.dvmrc b/.dvmrc index 83cf0d9..b0f3390 100644 --- a/.dvmrc +++ b/.dvmrc @@ -1 +1 @@ -1.29.1 +1.30.3 diff --git a/.env.sample b/.env.sample index 8526897..1a17a44 100644 --- a/.env.sample +++ b/.env.sample @@ -1 +1,13 @@ -USERBASE_APP_ID=get-from-userbase.com +PORT=8000 +BASE_URL="http://localhost:8000" +POSTGRESQL_HOST="localhost" +POSTGRESQL_USER="postgres" +POSTGRESQL_PASSWORD="fake" +POSTGRESQL_DBNAME="loggit" +POSTGRESQL_PORT=5432 +POSTGRESQL_CAFILE="" + +POSTMARK_SERVER_API_TOKEN="fake" + +PADDLE_VENDOR_ID="fake" +PADDLE_API_KEY="fake" diff --git a/.github/workflows/cron-check-subscriptions.yml b/.github/workflows/cron-check-subscriptions.yml new file mode 100644 index 0000000..4952634 --- /dev/null +++ b/.github/workflows/cron-check-subscriptions.yml @@ -0,0 +1,25 @@ +name: "Cron: Check subscriptions" + +on: + workflow_dispatch: + schedule: + # At 04:05 every day. + - cron: '5 4 * * *' + +jobs: + cron-cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: denoland/setup-deno@v1 + with: + deno-version: v1.30.3 + - env: + POSTGRESQL_HOST: ${{ secrets.POSTGRESQL_HOST }} + POSTGRESQL_USER: ${{ secrets.POSTGRESQL_USER }} + POSTGRESQL_PASSWORD: ${{ secrets.POSTGRESQL_PASSWORD }} + POSTGRESQL_DBNAME: ${{ secrets.POSTGRESQL_DBNAME }} + POSTGRESQL_PORT: ${{ secrets.POSTGRESQL_PORT }} + POSTGRESQL_CAFILE: ${{ secrets.POSTGRESQL_CAFILE }} + run: | + make crons/check-subscriptions diff --git a/.github/workflows/cron-cleanup.yml b/.github/workflows/cron-cleanup.yml new file mode 100644 index 0000000..78548c9 --- /dev/null +++ b/.github/workflows/cron-cleanup.yml @@ -0,0 +1,25 @@ +name: "Cron: Cleanup" + +on: + workflow_dispatch: + schedule: + # At 03:04 every day. + - cron: '4 3 * * *' + +jobs: + cron-cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: denoland/setup-deno@v1 + with: + deno-version: v1.30.3 + - env: + POSTGRESQL_HOST: ${{ secrets.POSTGRESQL_HOST }} + POSTGRESQL_USER: ${{ secrets.POSTGRESQL_USER }} + POSTGRESQL_PASSWORD: ${{ secrets.POSTGRESQL_PASSWORD }} + POSTGRESQL_DBNAME: ${{ secrets.POSTGRESQL_DBNAME }} + POSTGRESQL_PORT: ${{ secrets.POSTGRESQL_PORT }} + POSTGRESQL_CAFILE: ${{ secrets.POSTGRESQL_CAFILE }} + run: | + make crons/cleanup diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37ea88e..85b996a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,12 @@ jobs: - uses: actions/checkout@v3 - uses: denoland/setup-deno@v1 with: - deno-version: v1.29.1 + deno-version: v1.30.3 + - run: docker-compose pull + - uses: jpribyl/action-docker-layer-caching@v0.1.1 + continue-on-error: true - run: | + cp .env.sample .env + docker-compose up -d + make migrate-db make test diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..e1827d5 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,3 @@ +localhost + +reverse_proxy * localhost:8000 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5355121 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ + +FROM denoland/deno:1.30.3 + +EXPOSE 8000 + +WORKDIR /app + +# Prefer not to run as root. +USER deno + +# These steps will be re-run upon each file change in your working directory: +ADD . /app + +# Compile the main app so that it doesn't need to be compiled each startup/entry. +RUN deno cache --reload main.ts + +CMD ["make", "start"] diff --git a/Makefile b/Makefile index db70e4d..ac5b86a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: start start: - deno run --watch --allow-net --allow-read=public,pages,.env,.env.defaults,.env.example --allow-env main.ts + deno run --watch --allow-net --allow-read --allow-env main.ts .PHONY: format format: @@ -10,4 +10,20 @@ format: test: deno fmt --check deno lint - deno test --allow-net --allow-read=public,pages,.env,.env.defaults,.env.example --allow-env --check=all + deno test --allow-net --allow-read --allow-env --check + +.PHONY: migrate-db +migrate-db: + deno run --allow-net --allow-read --allow-env migrate-db.ts + +.PHONY: crons/check-subscriptions +crons/check-subscriptions: + deno run --allow-net --allow-read --allow-env crons/check-subscriptions.ts + +.PHONY: crons/cleanup +crons/cleanup: + deno run --allow-net --allow-read --allow-env crons/cleanup.ts + +.PHONY: exec-db +exec-db: + docker exec -it -u postgres $(shell basename $(CURDIR))_postgresql_1 psql diff --git a/README.md b/README.md index 0370b3b..55ff8f9 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,61 @@ This is the web app for the [Loggit app](https://loggit.net), built with [Deno](https://deno.land) and deployed to [Deno Deploy](https://deno.com/deploy). -This is v2, which is [end-to-end encrypted via userbase](https://userbase.com), and works via web on any device (it's a PWA - Progressive Web App). +This is v3, which is [end-to-end encrypted with open Web Standards](https://en.wikipedia.org/wiki/End-to-end_encryption), and works via web on any device (it's a PWA - Progressive Web App). -It's not compatible with Loggit v1 (not end-to-end encrypted), which you can still get locally from [this commit](https://github.com/BrunoBernardino/loggit-web/tree/84052355f46472998b8b60975304d69740513f21) and built in [here](https://v1.loggit.net). You can still export and import the data as the JSON format is the same (unencrypted). +It's not compatible with Loggit v2 ([end-to-end encrypted via Userbase](https://userbase.com)) which you can still get locally from [this commit](https://github.com/BrunoBernardino/loggit-web/tree/39df07c17dff608654deea5e9047e28a782b0cd2), nor v1 (not end-to-end encrypted), which you can still get locally from [this commit](https://github.com/BrunoBernardino/loggit-web/tree/84052355f46472998b8b60975304d69740513f21). You can still export and import the data as the JSON format is the same across all 3 versions (unencrypted). + +## Self-host it! + +[![Deploy to DigitalOcean](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/BrunoBernardino/loggit-web) + +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/BrunoBernardino/loggit-web) + +Or check the [Development section below](#development). + +> **NOTE:** You don't need to have emails (Postmark) and subscriptions (Paddle) setup to have the app work. Those are only used for allowing others to automatically manage their account. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly. + +## Framework-less + +This right here is vanilla TypeScript and JavaScript using Web Standards. It's very easy to update and maintain. + +It's meant to have no unnecessary dependencies, packagers, or bundlers. Just vanilla, simple stuff. ## Requirements -This was tested with `deno`'s version in the `.dvmrc` file, though it's possible other versions might work. +This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work. + +For the PostgreSQL dependency (used when running locally, self-hosted, or in CI), you should have `Docker` and `docker-compose` installed. -There are no other dependencies. **Deno**! +If you want to run the app locally with SSL (Web Crypto standards require `https` except for Chrome), you can use [`Caddy`](https://caddyserver.com) (there's a `Caddyfile` that proxies `https://localhost` to the Deno app). + +Don't forget to set up your `.env` file based on `.env.sample`. ## Development ```sh -$ make start -$ make format -$ make test +$ docker-compose up # (optional) runs docker with postgres, locally +$ sudo caddy run # (optional) runs an https proxy for the deno app +$ make migrate-db # runs any missing database migrations +$ make start # runs the app +$ make format # formats the code +$ make test # runs tests ``` -## Structure +## Other less-used commands + +```sh +$ make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "loggit"; CREATE DATABASE "loggit";` +``` -This is vanilla JS, web standards, no frameworks. If you'd like to see/use [the Next.js version deployed to AWS via Serverless, check this commit](https://github.com/BrunoBernardino/loggit-web/tree/065cdc1f3eee3dfd46c70803fcea00906847a5b3). +## Structure - Backend routes are defined at `routes.ts`. -- Static files are defined at `public/`. +- Publicly-available files are defined at `public/`. - Pages are defined at `pages/`. +- Cron jobs are defined at `crons/`. +- Reusable bits of code are defined at `lib/`. +- Database migrations are defined at `db-migrations/`. ## Deployment @@ -36,5 +66,8 @@ This is vanilla JS, web standards, no frameworks. If you'd like to see/use [the ## TODOs: +- [ ] Subscriptions check cron + +--- + - [ ] Enable true offline mode (securely cache data, allow read-only) - - https://github.com/smallbets/userbase/issues/255 has interesting ideas, while it's not natively supported diff --git a/components/footer.ts b/components/footer.ts index a7d3eba..afb792d 100644 --- a/components/footer.ts +++ b/components/footer.ts @@ -1,4 +1,4 @@ -import { html } from '../lib/utils.ts'; +import { helpEmail, html } from '/lib/utils.ts'; export default function footer() { return html` @@ -54,7 +54,7 @@ export default function footer() { diff --git a/components/header.ts b/components/header.ts index 0b8fea4..d72336a 100644 --- a/components/header.ts +++ b/components/header.ts @@ -1,4 +1,4 @@ -import { html } from '../lib/utils.ts'; +import { html } from '/lib/utils.ts'; export default function header(currentPath: string) { return html` diff --git a/components/loading.ts b/components/loading.ts index 49b59f7..a3486a2 100644 --- a/components/loading.ts +++ b/components/loading.ts @@ -1,4 +1,4 @@ -import { html } from '../lib/utils.ts'; +import { html } from '/lib/utils.ts'; export default function loading() { return html` diff --git a/components/modals/verification-code.ts b/components/modals/verification-code.ts new file mode 100644 index 0000000..25e0b9a --- /dev/null +++ b/components/modals/verification-code.ts @@ -0,0 +1,31 @@ +import { html } from '/lib/utils.ts'; + +export default function verificationCodeModal() { + return html` + + `; +} diff --git a/crons/check-subscriptions.ts b/crons/check-subscriptions.ts new file mode 100644 index 0000000..a273572 --- /dev/null +++ b/crons/check-subscriptions.ts @@ -0,0 +1,56 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import { getSubscribedUsers } from '/lib/providers/paddle.ts'; +import { updateUser } from '/lib/data-utils.ts'; +import { User } from '/lib/types.ts'; +import { PADDLE_MONTHLY_PLAN_ID } from '/lib/utils.ts'; + +const db = new Database(); + +async function checkSubscriptions() { + try { + const users = await db.query( + sql`SELECT * FROM "loggit_users" WHERE "status" IN ('active', 'trial')`, + ); + + let updatedUsers = 0; + + const paddleUsers = await getSubscribedUsers(); + + for (const paddleUser of paddleUsers) { + const matchingUser = users.find((user) => user.email === paddleUser.user_email); + + if (matchingUser) { + if (!matchingUser.subscription.external.paddle) { + matchingUser.subscription.external.paddle = { + user_id: paddleUser.user_id.toString(), + subscription_id: paddleUser.subscription_id.toString(), + update_url: paddleUser.update_url, + cancel_url: paddleUser.cancel_url, + }; + } + + matchingUser.subscription.isMonthly = paddleUser.plan_id === PADDLE_MONTHLY_PLAN_ID; + matchingUser.subscription.updated_at = new Date().toISOString(); + matchingUser.subscription.expires_at = new Date(paddleUser.next_payment.date).toISOString(); + + if (['active', 'paused'].includes(paddleUser.state)) { + matchingUser.status = 'active'; + } else if (paddleUser.state === 'trialing') { + matchingUser.status = 'trial'; + } else { + matchingUser.status = 'inactive'; + } + + await updateUser(matchingUser); + + ++updatedUsers; + } + } + + console.log('Updated', updatedUsers, 'users'); + } catch (error) { + console.log(error); + } +} + +await checkSubscriptions(); diff --git a/crons/cleanup.ts b/crons/cleanup.ts new file mode 100644 index 0000000..e7113e1 --- /dev/null +++ b/crons/cleanup.ts @@ -0,0 +1,75 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import { User } from '/lib/types.ts'; + +const db = new Database(); + +async function cleanupSessions() { + const yesterday = new Date(new Date().setUTCDate(new Date().getUTCDate() - 1)); + + try { + const result = await db.query<{ count: number }>( + sql`WITH "deleted" AS ( + DELETE FROM "loggit_user_sessions" WHERE "expires_at" <= $1 RETURNING * + ) + SELECT COUNT(*) FROM "deleted"`, + [ + yesterday.toISOString().substring(0, 10), + ], + ); + + console.log('Deleted', result[0].count, 'user sessions'); + } catch (error) { + console.log(error); + } +} + +async function cleanupInactiveUsers() { + const thirtyDaysAgo = new Date(new Date().setUTCDate(new Date().getUTCDate() - 30)); + + try { + const result = await db.query>( + sql`SELECT "id" FROM "loggit_users" WHERE "status" = 'inactive' AND "subscription" ->> 'expires_at' <= $1`, + [ + thirtyDaysAgo.toISOString().substring(0, 10), + ], + ); + + const userIdsToDelete = result.map((user) => user.id); + + await db.query( + sql`DELETE FROM "loggit_user_sessions" WHERE "user_id" IN ($1)`, + [ + userIdsToDelete, + ], + ); + + await db.query( + sql`DELETE FROM "loggit_verification_codes" WHERE "user_id" IN ($1)`, + [ + userIdsToDelete, + ], + ); + + await db.query( + sql`DELETE FROM "loggit_events" WHERE "user_id" IN ($1)`, + [ + userIdsToDelete, + ], + ); + + await db.query( + sql`DELETE FROM "loggit_users" WHERE "id" IN ($1)`, + [ + userIdsToDelete, + ], + ); + + console.log('Deleted', userIdsToDelete.length, 'users'); + } catch (error) { + console.log(error); + } +} + +await cleanupInactiveUsers(); + +await cleanupSessions(); diff --git a/db-migrations/001-base.pgsql b/db-migrations/001-base.pgsql new file mode 100644 index 0000000..49a0374 --- /dev/null +++ b/db-migrations/001-base.pgsql @@ -0,0 +1,196 @@ +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + + +-- +-- Name: loggit_user_sessions; Type: TABLE; Schema: public; Owner: brn +-- + +CREATE TABLE public.loggit_user_sessions ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + expires_at timestamp with time zone NOT NULL, + verified BOOLEAN NOT NULL, + last_seen_at timestamp with time zone DEFAULT now(), + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.loggit_user_sessions OWNER TO brn; + + +-- +-- Name: loggit_verification_codes; Type: TABLE; Schema: public; Owner: brn +-- + +CREATE TABLE public.loggit_verification_codes ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + code character varying NOT NULL, + verification jsonb NOT NULL, + expires_at timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.loggit_verification_codes OWNER TO brn; + + +-- +-- Name: loggit_events; Type: TABLE; Schema: public; Owner: brn +-- + +CREATE TABLE public.loggit_events ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + name text NOT NULL, + date character varying NOT NULL, + extra jsonb NOT NULL +); + + +ALTER TABLE public.loggit_events OWNER TO brn; + + +-- +-- Name: loggit_users; Type: TABLE; Schema: public; Owner: brn +-- + +CREATE TABLE public.loggit_users ( + id uuid DEFAULT gen_random_uuid(), + email character varying NOT NULL, + encrypted_key_pair text NOT NULL, + subscription jsonb NOT NULL, + status character varying NOT NULL, + extra jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.loggit_users OWNER TO brn; + + +-- +-- Name: loggit_migrations; Type: TABLE; Schema: public; Owner: brn +-- + +CREATE TABLE public.loggit_migrations ( + id uuid DEFAULT gen_random_uuid(), + name character varying(100) NOT NULL, + executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE public.loggit_migrations OWNER TO brn; + + +-- +-- Name: loggit_user_sessions loggit_user_sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: brn +-- + +ALTER TABLE ONLY public.loggit_user_sessions + ADD CONSTRAINT loggit_user_sessions_pkey PRIMARY KEY (id); + + +-- +-- Name: loggit_verification_codes loggit_verification_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: brn +-- + +ALTER TABLE ONLY public.loggit_verification_codes + ADD CONSTRAINT loggit_verification_codes_pkey PRIMARY KEY (id); + + +-- +-- Name: loggit_events loggit_events_pkey; Type: CONSTRAINT; Schema: public; Owner: brn +-- + +ALTER TABLE ONLY public.loggit_events + ADD CONSTRAINT loggit_events_pkey PRIMARY KEY (id); + + +-- +-- Name: loggit_users loggit_users_pkey; Type: CONSTRAINT; Schema: public; Owner: brn +-- + +ALTER TABLE ONLY public.loggit_users + ADD CONSTRAINT loggit_users_pkey PRIMARY KEY (id); + + +-- +-- Name: loggit_user_sessions loggit_user_sessions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: brn +-- + +ALTER TABLE ONLY public.loggit_user_sessions + ADD CONSTRAINT loggit_user_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.loggit_users(id); + + +-- +-- Name: loggit_verification_codes loggit_verification_codes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: brn +-- + +ALTER TABLE ONLY public.loggit_verification_codes + ADD CONSTRAINT loggit_verification_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.loggit_users(id); + + +-- +-- Name: loggit_events loggit_events_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: brn +-- + +ALTER TABLE ONLY public.loggit_events + ADD CONSTRAINT loggit_events_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.loggit_users(id); + + +-- +-- Name: TABLE loggit_user_sessions; Type: ACL; Schema: public; Owner: brn +-- + +GRANT ALL ON TABLE public.loggit_user_sessions TO brn; + + +-- +-- Name: TABLE loggit_verification_codes; Type: ACL; Schema: public; Owner: brn +-- + +GRANT ALL ON TABLE public.loggit_verification_codes TO brn; + + +-- +-- Name: TABLE loggit_events; Type: ACL; Schema: public; Owner: brn +-- + +GRANT ALL ON TABLE public.loggit_events TO brn; + + +-- +-- Name: TABLE loggit_users; Type: ACL; Schema: public; Owner: brn +-- + +GRANT ALL ON TABLE public.loggit_users TO brn; + + +-- +-- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: brn +-- + +ALTER DEFAULT PRIVILEGES FOR ROLE brn IN SCHEMA public GRANT ALL ON SEQUENCES TO brn; + + +-- +-- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: brn +-- + +ALTER DEFAULT PRIVILEGES FOR ROLE brn IN SCHEMA public GRANT ALL ON FUNCTIONS TO brn; + + +-- +-- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: brn +-- + +ALTER DEFAULT PRIVILEGES FOR ROLE brn IN SCHEMA public GRANT ALL ON TABLES TO brn; diff --git a/deno.json b/deno.json index 24cb9bc..a244982 100644 --- a/deno.json +++ b/deno.json @@ -9,28 +9,31 @@ }, "files": { "exclude": [ - "public/js/stripe.js", - "public/js/sweetalert.js", - "public/js/userbase.js", - "public/js/userbase.js.map" + "public/js/sweetalert.js" ] } }, "lint": { "rules": { "exclude": [ - "no-explicit-any" + "no-explicit-any", + "no-window-prefix" ] }, "files": { "exclude": [ - "public/js/stripe.js", - "public/js/sweetalert.js", - "public/js/userbase.js", - "public/js/userbase.js.map" + "public/js/sweetalert.js" ] } }, + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "dom.asynciterable", + "deno.ns" + ] + }, "importMap": "./import_map.json", "lock": false } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1dd0681 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + postgresql: + image: postgres:15 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=fake + - POSTGRES_DB=loggit + restart: on-failure + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - 5432:5432 + ulimits: + memlock: + soft: -1 + hard: -1 + + # NOTE: This would be nice to develop with https:// locally, but it doesn't work, for whatever reason, so we need a system caddy instead + # caddy: + # image: caddy:2-alpine + # restart: unless-stopped + # command: caddy reverse-proxy --from https://localhost:443 --to http://localhost:8000 + # network_mode: "host" + # volumes: + # - caddy:/data + +volumes: + pgdata: + driver: local + # caddy: + # driver: local diff --git a/lib/data-utils.ts b/lib/data-utils.ts new file mode 100644 index 0000000..5b08c0b --- /dev/null +++ b/lib/data-utils.ts @@ -0,0 +1,355 @@ +import Database, { sql } from './interfaces/database.ts'; +import { Event, User, UserSession, VerificationCode } from './types.ts'; +import { generateRandomCode, splitArrayInChunks } from './utils.ts'; + +const db = new Database(); + +export const monthRegExp = new RegExp(/^\d{4}\-\d{2}$/); + +export async function getUserByEmail(email: string) { + const lowercaseEmail = email.toLowerCase().trim(); + + const user = (await db.query(sql`SELECT * FROM "loggit_users" WHERE "email" = $1 LIMIT 1`, [ + lowercaseEmail, + ]))[0]; + + return user; +} + +export async function getUserById(id: string) { + const user = (await db.query(sql`SELECT * FROM "loggit_users" WHERE "id" = $1 LIMIT 1`, [ + id, + ]))[0]; + + return user; +} + +export async function createUser(email: User['email'], encryptedKeyPair: User['encrypted_key_pair']) { + const trialDays = 14; + const now = new Date(); + const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays)); + + const subscription: User['subscription'] = { + external: {}, + expires_at: trialEndDate.toISOString(), + updated_at: now.toISOString(), + }; + + const newUser = (await db.query( + sql`INSERT INTO "loggit_users" ( + "email", + "subscription", + "status", + "encrypted_key_pair", + "extra" + ) VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ + email, + JSON.stringify(subscription), + 'trial', + encryptedKeyPair, + JSON.stringify({}), + ], + ))[0]; + + return newUser; +} + +export async function updateUser(user: User) { + await db.query( + sql`UPDATE "loggit_users" SET + "email" = $2, + "subscription" = $3, + "status" = $4, + "encrypted_key_pair" = $5, + "extra" = $6 + WHERE "id" = $1`, + [ + user.id, + user.email, + JSON.stringify(user.subscription), + user.status, + user.encrypted_key_pair, + JSON.stringify(user.extra), + ], + ); +} + +export async function deleteUser(userId: string) { + await db.query( + sql`DELETE FROM "loggit_user_sessions" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "loggit_verification_codes" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "loggit_events" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "loggit_users" WHERE "id" = $1`, + [ + userId, + ], + ); +} + +export async function getSessionById(id: string) { + const session = (await db.query( + sql`SELECT * FROM "loggit_user_sessions" WHERE "id" = $1 AND "expires_at" > now() LIMIT 1`, + [ + id, + ], + ))[0]; + + return session; +} + +export async function createSession(user: User, sessionExpiresAt?: Date) { + // Add new user session to the db + const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1)); + + const newSession: Omit = { + user_id: user.id, + expires_at: sessionExpiresAt || oneMonthFromToday, + last_seen_at: new Date(), + verified: false, + }; + + const newUserSessionResult = (await db.query( + sql`INSERT INTO "loggit_user_sessions" ( + "user_id", + "expires_at", + "verified", + "last_seen_at" + ) VALUES ($1, $2, $3, $4) + RETURNING *`, + [ + newSession.user_id, + newSession.expires_at, + newSession.verified, + newSession.last_seen_at, + ], + ))[0]; + + return newUserSessionResult; +} + +export async function updateSession(session: UserSession) { + await db.query( + sql`UPDATE "loggit_user_sessions" SET + "expires_at" = $2, + "verified" = $3, + "last_seen_at" = $4 + WHERE "id" = $1`, + [ + session.id, + session.expires_at, + session.verified, + session.last_seen_at, + ], + ); +} + +export async function validateUserAndSession(userId: string, sessionId: string, acceptUnverifiedSession = false) { + const user = await getUserById(userId); + + if (!user) { + throw new Error('Not Found'); + } + + const session = await getSessionById(sessionId); + + if (!session || session.user_id !== user.id || (!session.verified && !acceptUnverifiedSession)) { + throw new Error('Not Found'); + } + + session.last_seen_at = new Date(); + + await updateSession(session); + + return { user, session }; +} + +export async function createVerificationCode( + user: User, + session: UserSession, + type: VerificationCode['verification']['type'], +) { + const inThirtyMinutes = new Date(new Date().setUTCMinutes(new Date().getUTCMinutes() + 30)); + + const code = generateRandomCode(); + + const newVerificationCode: Omit = { + user_id: user.id, + code, + expires_at: inThirtyMinutes, + verification: { + id: session.id, + type, + }, + }; + + await db.query( + sql`INSERT INTO "loggit_verification_codes" ( + "user_id", + "code", + "expires_at", + "verification" + ) VALUES ($1, $2, $3, $4) + RETURNING "id"`, + [ + newVerificationCode.user_id, + newVerificationCode.code, + newVerificationCode.expires_at, + JSON.stringify(newVerificationCode.verification), + ], + ); + + return code; +} + +export async function validateVerificationCode( + user: User, + session: UserSession, + code: string, + type: VerificationCode['verification']['type'], +) { + const verificationCode = (await db.query( + sql`SELECT * FROM "loggit_verification_codes" + WHERE "user_id" = $1 AND + "code" = $2 AND + "verification" ->> 'type' = $3 AND + "verification" ->> 'id' = $4 AND + "expires_at" > now() + LIMIT 1`, + [ + user.id, + code, + type, + session.id, + ], + ))[0]; + + if (verificationCode) { + await db.query( + sql`DELETE FROM "loggit_verification_codes" WHERE "id" = $1`, + [ + verificationCode.id, + ], + ); + } else { + throw new Error('Not Found'); + } +} + +export async function getAllEvents(userId: string) { + const events = await db.query( + sql`SELECT * FROM "loggit_events" + WHERE "user_id" = $1 + ORDER BY "date" DESC`, + [ + userId, + ], + ); + + return events; +} + +export async function getEventsByMonth(userId: string, month: string) { + const events = await db.query( + sql`SELECT * FROM "loggit_events" + WHERE "user_id" = $1 AND + "date" >= '${month}-01' AND + "date" <= '${month}-31' + ORDER BY "date" DESC`, + [ + userId, + ], + ); + + return events; +} + +export async function createEvent(event: Omit) { + const newEvent = (await db.query( + sql`INSERT INTO "loggit_events" ( + "user_id", + "name", + "date", + "extra" + ) VALUES ($1, $2, $3, $4) + RETURNING *`, + [ + event.user_id, + event.name, + event.date, + JSON.stringify(event.extra), + ], + ))[0]; + + return newEvent; +} + +export async function updateEvent(event: Event) { + await db.query( + sql`UPDATE "loggit_events" SET + "name" = $2, + "date" = $3, + "extra" = $4 + WHERE "id" = $1`, + [ + event.id, + event.name, + event.date, + JSON.stringify(event.extra), + ], + ); +} + +export async function deleteEvent(eventId: string) { + await db.query( + sql`DELETE FROM "loggit_events" WHERE "id" = $1`, + [ + eventId, + ], + ); +} + +export async function deleteAllEvents(userId: string) { + await db.query( + sql`DELETE FROM "loggit_events" WHERE "user_id" = $1`, + [ + userId, + ], + ); +} + +export async function importUserData(userId: string, events: Omit[]) { + const addEventChunks = splitArrayInChunks( + events, + 100, // import in transactions of 100 events each + ); + + for (const eventsToAdd of addEventChunks) { + await db.query(sql`BEGIN;`); + + for (const event of eventsToAdd) { + await createEvent({ ...event, user_id: userId }); + } + + await db.query(sql`COMMIT;`); + } +} diff --git a/lib/interfaces/database.ts b/lib/interfaces/database.ts new file mode 100644 index 0000000..30d969e --- /dev/null +++ b/lib/interfaces/database.ts @@ -0,0 +1,76 @@ +import { Client } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'; +import 'std/dotenv/load.ts'; + +const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || ''; +const POSTGRESQL_USER = Deno.env.get('POSTGRESQL_USER') || ''; +const POSTGRESQL_PASSWORD = Deno.env.get('POSTGRESQL_PASSWORD') || ''; +const POSTGRESQL_DBNAME = Deno.env.get('POSTGRESQL_DBNAME') || ''; +const POSTGRESQL_PORT = Deno.env.get('POSTGRESQL_PORT') || ''; +const POSTGRESQL_CAFILE = Deno.env.get('POSTGRESQL_CAFILE') || ''; + +const tls = POSTGRESQL_CAFILE + ? { + enabled: true, + enforce: false, + caCertificates: [await Deno.readTextFile(POSTGRESQL_CAFILE)], + } + : { + enabled: true, + enforce: false, + }; + +export default class Database { + protected db?: Client; + + constructor(connectNow = false) { + if (connectNow) { + this.connectToPostgres(); + } + } + + protected async connectToPostgres() { + if (this.db) { + return this.db; + } + + const postgresClient = new Client({ + user: POSTGRESQL_USER, + password: POSTGRESQL_PASSWORD, + database: POSTGRESQL_DBNAME, + hostname: POSTGRESQL_HOST, + port: POSTGRESQL_PORT, + tls, + }); + + await postgresClient.connect(); + + this.db = postgresClient; + } + + protected async disconnectFromPostgres() { + if (!this.db) { + return; + } + + await this.db.end(); + + this.db = undefined; + } + + public close() { + this.disconnectFromPostgres(); + } + + public async query(sql: string, args?: any[]) { + if (!this.db) { + await this.connectToPostgres(); + } + + const result = await this.db!.queryObject(sql, args); + + return result.rows; + } +} + +// This allows us to have nice SQL syntax highlighting in template literals +export const sql = String.raw; diff --git a/lib/providers/paddle.ts b/lib/providers/paddle.ts new file mode 100644 index 0000000..4717006 --- /dev/null +++ b/lib/providers/paddle.ts @@ -0,0 +1,65 @@ +import 'std/dotenv/load.ts'; + +import { PADDLE_VENDOR_ID } from '/lib/utils.ts'; + +const PADDLE_API_KEY = Deno.env.get('PADDLE_API_KEY') || ''; + +interface PaddleUser { + subscription_id: number; + plan_id: number; + user_id: number; + user_email: string; + state: 'active' | 'trialing' | 'paused' | 'deleted' | 'past_due'; + signup_date: string; + next_payment: { + amount: number; + currency: string; + date: string; + }; + update_url: string; + cancel_url: string; +} + +interface PaddleResponse { + success: boolean; + error?: { + code: number; + message: string; + }; + response?: PaddleUser[]; +} + +function getApiRequestHeaders() { + return { + 'Accept': 'application/json; charset=utf-8', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + }; +} + +export async function getSubscribedUsers(paddlePlanId?: string) { + const body: { vendor_id: string; vendor_auth_code: string; results_per_page: string; plan_id?: string } = { + vendor_id: PADDLE_VENDOR_ID, + vendor_auth_code: PADDLE_API_KEY, + results_per_page: '100', + }; + + if (paddlePlanId) { + body.plan_id = paddlePlanId; + } + + // const response = await fetch('https://sandbox-vendors.paddle.com/api/2.0/subscription/users', { // Sandbox + const response = await fetch('https://vendors.paddle.com/api/2.0/subscription/users', { // Production + method: 'POST', + headers: getApiRequestHeaders(), + body: new URLSearchParams(Object.entries(body)).toString(), + }); + + const result = (await response.json()) as PaddleResponse; + + if (!result.success || !result.response) { + console.log(JSON.stringify({ result }, null, 2)); + throw new Error(`Failed to make API request: "${result}"`); + } + + return result.response!; +} diff --git a/lib/providers/postmark.ts b/lib/providers/postmark.ts new file mode 100644 index 0000000..10efbbb --- /dev/null +++ b/lib/providers/postmark.ts @@ -0,0 +1,153 @@ +import 'std/dotenv/load.ts'; + +import { helpEmail } from '/lib/utils.ts'; + +const POSTMARK_SERVER_API_TOKEN = Deno.env.get('POSTMARK_SERVER_API_TOKEN') || ''; + +interface PostmarkResponse { + To: string; + SubmittedAt: string; + MessageID: string; + ErrorCode: number; + Message: string; +} + +type TemplateAlias = 'verify-login' | 'verify-delete' | 'verify-update' | 'update-paddle-email'; + +function getApiRequestHeaders() { + return { + 'X-Postmark-Server-Token': POSTMARK_SERVER_API_TOKEN, + 'Accept': 'application/json; charset=utf-8', + 'Content-Type': 'application/json; charset=utf-8', + }; +} + +interface PostmarkEmailWithTemplateRequestBody { + TemplateId?: number; + TemplateAlias: TemplateAlias; + TemplateModel: { + [key: string]: any; + }; + InlineCss?: boolean; + From: string; + To: string; + Cc?: string; + Bcc?: string; + Tag?: string; + ReplyTo?: string; + Headers?: { Name: string; Value: string }[]; + TrackOpens?: boolean; + TrackLinks?: 'None' | 'HtmlAndText' | 'HtmlOnly' | 'TextOnly'; + Attachments?: { Name: string; Content: string; ContentType: string }[]; + Metadata?: { + [key: string]: string; + }; + MessageStream: 'outbound' | 'broadcast'; +} + +async function sendEmailWithTemplate( + to: string, + templateAlias: TemplateAlias, + data: PostmarkEmailWithTemplateRequestBody['TemplateModel'], + attachments: PostmarkEmailWithTemplateRequestBody['Attachments'] = [], + cc?: string, +) { + const email: PostmarkEmailWithTemplateRequestBody = { + From: helpEmail, + To: to, + TemplateAlias: templateAlias, + TemplateModel: data, + MessageStream: 'outbound', + }; + + if (attachments?.length) { + email.Attachments = attachments; + } + + if (cc) { + email.Cc = cc; + } + + const postmarkResponse = await fetch('https://api.postmarkapp.com/email/withTemplate', { + method: 'POST', + headers: getApiRequestHeaders(), + body: JSON.stringify(email), + }); + const postmarkResult = (await postmarkResponse.json()) as PostmarkResponse; + + if (postmarkResult.ErrorCode !== 0 || postmarkResult.Message !== 'OK') { + console.log(JSON.stringify({ postmarkResult }, null, 2)); + throw new Error(`Failed to send email "${templateAlias}"`); + } +} + +export async function sendVerifyLoginEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + }; + + await sendEmailWithTemplate(email, 'verify-login', data); +} + +export async function sendVerifyDeleteDataEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + deletionSubject: 'all your data', + }; + + await sendEmailWithTemplate(email, 'verify-delete', data); +} + +export async function sendVerifyDeleteAccountEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + deletionSubject: 'your account', + }; + + await sendEmailWithTemplate(email, 'verify-delete', data); +} + +export async function sendVerifyUpdateEmailEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + updateSubject: 'your email', + }; + + await sendEmailWithTemplate(email, 'verify-update', data); +} + +export async function sendVerifyUpdatePasswordEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + updateSubject: 'your password', + }; + + await sendEmailWithTemplate(email, 'verify-update', data); +} + +export async function sendUpdateEmailInPaddleEmail( + oldEmail: string, + newEmail: string, +) { + const data = { + oldEmail, + newEmail, + }; + + await sendEmailWithTemplate(helpEmail, 'update-paddle-email', data); +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..d3beae4 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,61 @@ +export type EncryptedData = string; + +export interface KeyPair { + publicKeyJwk: JsonWebKey; + privateKeyJwk: JsonWebKey; +} + +export interface User { + id: string; + email: string; + encrypted_key_pair: EncryptedData; + subscription: { + external: { + paddle?: { + user_id: string; + subscription_id: string; + update_url: string; + cancel_url: string; + }; + stripe?: { + user_id: string; + subscription_id: string; + }; + }; + isMonthly?: boolean; + expires_at: string; + updated_at: string; + }; + status: 'trial' | 'active' | 'inactive'; + extra: Record; // NOTE: Here for potential future fields + created_at: Date; +} + +export interface UserSession { + id: string; + user_id: string; + expires_at: Date; + verified: boolean; + last_seen_at: Date; + created_at: Date; +} + +export interface VerificationCode { + id: string; + user_id: string; + code: string; + verification: { + type: 'session' | 'user-update' | 'data-delete' | 'user-delete'; + id: string; + }; + expires_at: Date; + created_at: Date; +} + +export interface Event { + id: string; + user_id: User['id']; + name: EncryptedData; + date: string; + extra: Record; // NOTE: Here for potential future fields +} diff --git a/lib/utils.ts b/lib/utils.ts index c18d9e8..a1e32da 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,18 +1,26 @@ import 'std/dotenv/load.ts'; +import { emit } from 'https://deno.land/x/emit@0.15.0/mod.ts'; +import sass from 'https://deno.land/x/denosass@1.0.6/mod.ts'; +import { serveFile } from 'std/http/file_server.ts'; -import header from '../components/header.ts'; -import footer from '../components/footer.ts'; -import loading from '../components/loading.ts'; +import header from '/components/header.ts'; +import footer from '/components/footer.ts'; +import loading from '/components/loading.ts'; // This allows us to have nice html syntax highlighting in template literals export const html = String.raw; -const USERBASE_APP_ID = Deno.env.get('USERBASE_APP_ID') || ''; -const sessionLengthInHours = 90 * 24; // 3 months +export const PORT = Deno.env.get('PORT') || 8000; +export const PADDLE_VENDOR_ID = Deno.env.get('PADDLE_VENDOR_ID') || ''; +// export const PADDLE_MONTHLY_PLAN_ID = 45375; // Sandbox +export const PADDLE_MONTHLY_PLAN_ID = 814705; // Production +// export const PADDLE_YEARLY_PLAN_ID = 45376; // Sandbox +export const PADDLE_YEARLY_PLAN_ID = 814704; // Production -export const baseUrl = 'https://app.loggit.net'; +export const baseUrl = Deno.env.get('BASE_URL') || 'https://app.loggit.net'; export const defaultTitle = 'Loggit — Log your unscheduled events'; export const defaultDescription = 'Simple and encrypted event management.'; +export const helpEmail = 'help@loggit.net'; export interface PageContentResult { htmlContent: string; @@ -45,6 +53,7 @@ function basicLayout(htmlContent: string, { currentPath, titlePrefix, descriptio + @@ -61,16 +70,15 @@ function basicLayout(htmlContent: string, { currentPath, titlePrefix, descriptio ${footer()} - - + `; @@ -81,7 +89,7 @@ export function basicLayoutResponse(htmlContent: string, options: BasicLayoutOpt headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': - 'default-src \'self\' https://*.userbase.com wss://*.userbase.com https://*.stripe.com data: blob:; child-src \'self\' data: blob: https://*.stripe.com; img-src \'self\' data: blob: https://*.stripe.com; style-src \'self\' \'unsafe-inline\' https://*.stripe.com; script-src \'self\' \'unsafe-inline\' \'unsafe-eval\';', + 'default-src \'self\'; child-src \'self\' https://buy.paddle.com/ https://sandbox-buy.paddle.com/; img-src \'self\' https://cdn.paddle.com/paddle/ https://sandbox-cdn.paddle.com/paddle/; style-src \'self\' \'unsafe-inline\' https://cdn.paddle.com/paddle/ https://sandbox-cdn.paddle.com/paddle/; script-src \'self\' \'unsafe-inline\' \'unsafe-eval\' https://cdn.paddle.com/paddle/ https://sandbox-cdn.paddle.com/paddle/;', 'x-frame-options': 'DENY', 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', }, @@ -89,125 +97,91 @@ export function basicLayoutResponse(htmlContent: string, options: BasicLayoutOpt } export function isRunningLocally(urlPatternResult: URLPatternResult) { - return urlPatternResult.hostname.input === 'localhost'; + return ['localhost', 'loggit.local'].includes(urlPatternResult.hostname.input); } -// NOTE: The functions below are used in the frontend, but this copy allows for easier testing and type-checking - export function escapeHtml(unsafe: string) { return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') .replaceAll('\'', '''); } -type SortableByDate = { date: string }; -export function sortByDate( - objectA: SortableByDate, - objectB: SortableByDate, -) { - if (objectA.date < objectB.date) { - return -1; - } - if (objectA.date > objectB.date) { - return 1; - } - return 0; +async function transpileTs(content: string, specifier: URL) { + const urlStr = specifier.toString(); + const result = await emit(specifier, { + load(specifier: string) { + if (specifier !== urlStr) { + return Promise.resolve({ kind: 'module', specifier, content: '' }); + } + return Promise.resolve({ kind: 'module', specifier, content }); + }, + }); + return result[urlStr]; } -type SortableByCount = { count: number }; -export function sortByCount( - objectA: SortableByCount, - objectB: SortableByCount, -) { - if (objectA.count < objectB.count) { - return 1; - } - if (objectA.count > objectB.count) { - return -1; - } - return 0; -} +export async function serveFileWithTs(request: Request, filePath: string, extraHeaders?: ResponseInit['headers']) { + const response = await serveFile(request, filePath); -export function splitArrayInChunks(array: any[], chunkLength: number) { - const chunks = []; - let chunkIndex = 0; - const arrayLength = array.length; - - while (chunkIndex < arrayLength) { - chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength)); + if (response.status !== 200) { + return response; } - return chunks; -} - -export function uniqueBy( - array: any[], - predicate: string | ((item: any) => any), -) { - const filter = typeof predicate === 'function' ? predicate : (object: any) => object[predicate]; - - return [ - ...array - .reduce((map, item) => { - const key = item === null || item === undefined ? item : filter(item); - - map.has(key) || map.set(key, item); - - return map; - }, new Map()) - .values(), - ]; + const tsCode = await response.text(); + const jsCode = await transpileTs(tsCode, new URL('file:///src.ts')); + const { headers } = response; + headers.set('content-type', 'application/javascript; charset=utf-8'); + headers.delete('content-length'); + + return new Response(jsCode, { + status: response.status, + statusText: response.statusText, + headers, + ...(extraHeaders || {}), + }); } -export function dateDiffInDays(startDate: Date, endDate: Date) { - return Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); -} +function transpileSass(content: string) { + const compiler = sass(content); -interface GroupedEvent { - count: number; - firstLog: string; - lastLog: string; + return compiler.to_string('compressed') as string; } -export function calculateFrequencyFromGrouppedEvent(groupedEvent: GroupedEvent) { - const monthDifference = Math.round( - Math.abs(dateDiffInDays(new Date(groupedEvent.firstLog), new Date(groupedEvent.lastLog)) / 30), - ); +export async function serveFileWithSass(request: Request, filePath: string, extraHeaders?: ResponseInit['headers']) { + const response = await serveFile(request, filePath); - // This event has only existed for less than 6 months, so we can't know if it'll repeat any more - if (monthDifference <= 6 && groupedEvent.count < 12) { - return `${groupedEvent.count || 1}x / year`; + if (response.status !== 200) { + return response; } - const frequencyNumberPerMonth = Math.round( - groupedEvent.count / monthDifference, - ); + const sassCode = await response.text(); + const cssCode = transpileSass(sassCode); + const { headers } = response; + headers.set('content-type', 'text/css; charset=utf-8'); + headers.delete('content-length'); + + return new Response(cssCode, { + status: response.status, + statusText: response.statusText, + headers, + ...(extraHeaders || {}), + }); +} - // When potentially less than once per month, check frequency per year - if (frequencyNumberPerMonth <= 1) { - const frequencyNumberPerYear = Math.round( - (groupedEvent.count / monthDifference) * 12, - ); +export function generateRandomCode(length = 6) { + const getRandomDigit = () => Math.floor(Math.random() * (10)); // 0-9 - if (frequencyNumberPerYear < 12) { - return `${frequencyNumberPerYear || 1}x / year`; - } - } + const codeDigits = Array.from({ length }).map(getRandomDigit); - if (frequencyNumberPerMonth < 15) { - return `${frequencyNumberPerMonth}x / month`; - } + return codeDigits.join(''); +} - const frequencyNumberPerWeek = Math.round( - groupedEvent.count / monthDifference / 4, - ); +export function splitArrayInChunks(array: T[], chunkLength: number) { + const chunks = []; + let chunkIndex = 0; + const arrayLength = array.length; - if (frequencyNumberPerWeek < 7) { - return `${frequencyNumberPerMonth}x / week`; + while (chunkIndex < arrayLength) { + chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength)); } - const frequencyNumberPerDay = Math.round( - groupedEvent.count / monthDifference / 30, - ); - - return `${frequencyNumberPerDay}x / day`; + return chunks; } diff --git a/lib/utils_test.ts b/lib/utils_test.ts index ddcaba0..fbac83c 100644 --- a/lib/utils_test.ts +++ b/lib/utils_test.ts @@ -1,5 +1,5 @@ -import { assertEquals } from 'https://deno.land/std@0.156.0/testing/asserts.ts'; -import { calculateFrequencyFromGrouppedEvent, dateDiffInDays, escapeHtml, splitArrayInChunks } from './utils.ts'; +import { assertEquals } from 'std/testing/asserts.ts'; +import { escapeHtml, generateRandomCode, splitArrayInChunks } from './utils.ts'; Deno.test('that escapeHtml works', () => { const tests = [ @@ -19,6 +19,25 @@ Deno.test('that escapeHtml works', () => { } }); +Deno.test('that generateRandomCode works', () => { + const tests = [ + { + length: 6, + }, + { + length: 7, + }, + { + length: 8, + }, + ]; + + for (const test of tests) { + const output = generateRandomCode(test.length); + assertEquals(output.length, test.length); + } +}); + Deno.test('that splitArrayInChunks works', () => { const tests = [ { @@ -83,137 +102,3 @@ Deno.test('that splitArrayInChunks works', () => { assertEquals(output, test.expected); } }); - -Deno.test('that dateDiffInDays works', () => { - const tests = [ - { - input: { - startDate: new Date('2022-01-01'), - endDate: new Date('2022-01-01'), - }, - expected: 0, - }, - { - input: { - startDate: new Date('2022-01-01'), - endDate: new Date('2022-01-02'), - }, - expected: 1, - }, - { - input: { - startDate: new Date('2022-01-01'), - endDate: new Date('2022-12-02'), - }, - expected: 335, - }, - ]; - - for (const test of tests) { - const output = dateDiffInDays( - test.input.startDate, - test.input.endDate, - ); - assertEquals(output, test.expected); - } -}); - -Deno.test('that calculateFrequencyFromGrouppedEvent works', () => { - const tests = [ - { - input: { - count: 12, - firstLog: '2022-01-01', - lastLog: '2022-12-01', - }, - expected: '1x / month', - }, - { - input: { - count: 16, - firstLog: '2022-01-01', - lastLog: '2022-12-01', - }, - expected: '1x / month', - }, - { - input: { - count: 18, - firstLog: '2022-01-01', - lastLog: '2022-12-01', - }, - expected: '2x / month', - }, - { - input: { - count: 30, - firstLog: '2022-01-01', - lastLog: '2022-12-01', - }, - expected: '3x / month', - }, - { - input: { - count: 2, - firstLog: '2022-01-01', - lastLog: '2022-01-06', - }, - expected: '2x / year', - }, - { - input: { - count: 30, - firstLog: '2022-01-01', - lastLog: '2022-01-30', - }, - expected: '1x / day', - }, - { - input: { - count: 10, - firstLog: '2022-01-01', - lastLog: '2022-01-30', - }, - expected: '10x / year', - }, - { - input: { - count: 1, - firstLog: '2022-01-01', - lastLog: '2022-01-30', - }, - expected: '1x / year', - }, - { - input: { - count: 1, - firstLog: '2022-01-01', - lastLog: '2022-02-30', - }, - expected: '1x / year', - }, - { - input: { - count: 1, - firstLog: '2022-01-01', - lastLog: '2025-01-30', - }, - expected: '1x / year', - }, - { - input: { - count: 2, - firstLog: '2022-01-01', - lastLog: '2025-01-30', - }, - expected: '1x / year', - }, - ]; - - for (const test of tests) { - const output = calculateFrequencyFromGrouppedEvent( - test.input, - ); - assertEquals(output, test.expected); - } -}); diff --git a/migrate-db.ts b/migrate-db.ts new file mode 100644 index 0000000..40bf6b9 --- /dev/null +++ b/migrate-db.ts @@ -0,0 +1,90 @@ +import 'std/dotenv/load.ts'; + +import Database, { sql } from '/lib/interfaces/database.ts'; + +const migrationsDirectoryPath = `${Deno.cwd()}/db-migrations`; + +const migrationsDirectory = Deno.readDir(migrationsDirectoryPath); + +const db = new Database(); + +interface Migration { + id: string; + name: string; + executed_at: Date; +} + +async function getExecutedMigrations() { + const executedMigrations = new Set( + Array.from( + (await db.query(sql`SELECT * FROM "loggit_migrations" ORDER BY "name" ASC`)).map((migration) => + migration.name + ), + ), + ); + + return executedMigrations; +} + +async function getMissingMigrations() { + const existingMigrations: Set = new Set(); + + for await (const migrationFile of migrationsDirectory) { + // Skip non-files + if (!migrationFile.isFile) { + continue; + } + + // Skip files not in the "001-blah.pgsql" format + if (!migrationFile.name.match(/^\d+-.*(\.pgsql)$/)) { + continue; + } + + existingMigrations.add(migrationFile.name); + } + + // Add everything to run, by default + const migrationsToExecute = new Set([...existingMigrations]); + + try { + const executedMigrations = await getExecutedMigrations(); + + // Remove any existing migrations that were executed, from the list of migrations to execute + for (const executedMigration of executedMigrations) { + migrationsToExecute.delete(executedMigration); + } + } catch (_error) { + // The table likely doesn't exist, so run everything. + } + + return migrationsToExecute; +} + +async function runMigrations(missingMigrations: Set) { + for (const missingMigration of missingMigrations) { + console.log(`Running "${missingMigration}"...`); + + try { + const migrationSql = await Deno.readTextFile(`${migrationsDirectoryPath}/${missingMigration}`); + + await db.query(migrationSql); + + await db.query(sql`INSERT INTO "public"."loggit_migrations" ("name", "executed_at") VALUES ($1, NOW())`, [ + missingMigration, + ]); + + console.log('Success!'); + } catch (error) { + console.log('Failed!'); + console.error(error); + } + } +} + +const missingMigrations = await getMissingMigrations(); + +await runMigrations(missingMigrations); + +if (missingMigrations.size === 0) { + console.log('No migrations to run!'); +} diff --git a/pages/api/data.ts b/pages/api/data.ts new file mode 100644 index 0000000..c922c7b --- /dev/null +++ b/pages/api/data.ts @@ -0,0 +1,68 @@ +import { + createVerificationCode, + deleteAllEvents, + importUserData, + validateUserAndSession, + validateVerificationCode, +} from '/lib/data-utils.ts'; +import { sendVerifyDeleteDataEmail } from '/lib/providers/postmark.ts'; +import { Event } from '/lib/types.ts'; + +async function importDataAction(request: Request) { + const { user_id, session_id, events }: { + user_id: string; + session_id: string; + events: Omit[]; + } = await request.json(); + + if (!user_id || !session_id || !events) { + return new Response('Bad Request', { status: 400 }); + } + + const { user } = await validateUserAndSession(user_id, session_id); + + await importUserData(user.id, events); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +async function deleteDataAction(request: Request) { + const { user_id, session_id, code }: { user_id: string; session_id: string; code?: string } = await request.json(); + + if (!user_id || !session_id) { + return new Response('Bad Request', { status: 400 }); + } + + const { user, session } = await validateUserAndSession(user_id, session_id); + + if (!code) { + const verificationCode = await createVerificationCode(user, session, 'data-delete'); + + await sendVerifyDeleteDataEmail(user.email, verificationCode); + } else { + await validateVerificationCode(user, session, code, 'data-delete'); + + await deleteAllEvents(user.id); + } + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +export function pageAction(request: Request) { + switch (request.method) { + case 'POST': + return importDataAction(request); + case 'DELETE': + return deleteDataAction(request); + } + + return new Response('Not Implemented', { status: 501 }); +} + +export function pageContent() { + return new Response('Not Implemented', { status: 501 }); +} diff --git a/pages/api/events.ts b/pages/api/events.ts new file mode 100644 index 0000000..3114afb --- /dev/null +++ b/pages/api/events.ts @@ -0,0 +1,87 @@ +import { + createEvent, + deleteEvent, + getAllEvents, + getEventsByMonth, + monthRegExp, + updateEvent, + validateUserAndSession, +} from '/lib/data-utils.ts'; +import { Event } from '/lib/types.ts'; + +async function createOrUpdateEvent(request: Request) { + const { session_id, user_id, name, date, extra, id }: Omit & { session_id: string; id?: string } = + await request.json(); + + if (!session_id || !user_id || !name || !date || !extra) { + return new Response('Bad Request', { status: 400 }); + } + + if ((request.method === 'PATCH' && !id) || (request.method === 'POST' && id)) { + return new Response('Bad Request', { status: 400 }); + } + + await validateUserAndSession(user_id, session_id); + + if (request.method === 'PATCH') { + await updateEvent({ id: id!, user_id, name, date, extra }); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); + } + + const newEvent = await createEvent({ + user_id, + name, + date, + extra, + }); + + return new Response(JSON.stringify(newEvent), { headers: { 'Content-Type': 'application/json; charset=utf-8' } }); +} + +async function deleteEventAction(request: Request) { + const { user_id, session_id, id }: { user_id: string; session_id: string; id: string } = await request.json(); + + if (!user_id || !session_id || !id) { + return new Response('Bad Request', { status: 400 }); + } + + await validateUserAndSession(user_id, session_id); + + await deleteEvent(id); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +export function pageAction(request: Request) { + switch (request.method) { + case 'POST': + case 'PATCH': + return createOrUpdateEvent(request); + case 'DELETE': + return deleteEventAction(request); + } + + return new Response('Not Implemented', { status: 501 }); +} + +export async function pageContent(request: Request, _match: URLPatternResult) { + const urlSearchParams = new URL(request.url).searchParams; + const sessionId = urlSearchParams.get('session_id'); + const userId = urlSearchParams.get('user_id'); + const month = urlSearchParams.get('month'); + + if (!sessionId || !userId || !month || (!monthRegExp.test(month) && month !== 'all')) { + return new Response('Bad Request', { status: 400 }); + } + + const { user } = await validateUserAndSession(userId, sessionId); + + const events = await (month === 'all' ? getAllEvents(user.id) : getEventsByMonth(user.id, month)); + + return new Response(JSON.stringify(events), { headers: { 'Content-Type': 'application/json; charset=utf-8' } }); +} diff --git a/pages/api/session.ts b/pages/api/session.ts new file mode 100644 index 0000000..7365878 --- /dev/null +++ b/pages/api/session.ts @@ -0,0 +1,94 @@ +import { + createSession, + createVerificationCode, + getUserByEmail, + updateSession, + validateUserAndSession, + validateVerificationCode, +} from '/lib/data-utils.ts'; +import { sendVerifyLoginEmail } from '/lib/providers/postmark.ts'; + +async function validateSession(request: Request) { + const { email }: { email: string } = await request.json(); + + if (!email) { + return new Response('Bad Request', { status: 400 }); + } + + const user = await getUserByEmail(email); + + if (!user) { + return new Response('Not Found', { status: 404 }); + } + + const session = await createSession(user); + + if (!session) { + return new Response('Bad Request', { status: 400 }); + } + + const verificationCode = await createVerificationCode(user, session, 'session'); + + await sendVerifyLoginEmail(user.email, verificationCode); + + return new Response(JSON.stringify({ user, session_id: session.id }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +async function verifySession(request: Request) { + const { user_id, session_id, code }: { user_id: string; session_id: string; code: string } = await request.json(); + + if (!user_id || !session_id || !code) { + return new Response('Bad Request', { status: 400 }); + } + + const { user, session } = await validateUserAndSession(user_id, session_id, true); + + await validateVerificationCode(user, session, code, 'session'); + + session.verified = true; + + await updateSession(session); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +async function deleteSession(request: Request) { + const { user_id, session_id }: { user_id: string; session_id: string } = await request.json(); + + if (!user_id || !session_id) { + return new Response('Bad Request', { status: 400 }); + } + + const { session } = await validateUserAndSession(user_id, session_id); + + const yesterday = new Date(new Date().setUTCDate(new Date().getUTCDate() - 1)); + + session.expires_at = yesterday; + + await updateSession(session); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +export function pageAction(request: Request) { + switch (request.method) { + case 'POST': + return validateSession(request); + case 'PATCH': + return verifySession(request); + case 'DELETE': + return deleteSession(request); + } + + return new Response('Not Implemented', { status: 501 }); +} + +export function pageContent() { + return new Response('Not Implemented', { status: 501 }); +} diff --git a/pages/api/subscription.ts b/pages/api/subscription.ts new file mode 100644 index 0000000..daa2f5f --- /dev/null +++ b/pages/api/subscription.ts @@ -0,0 +1,44 @@ +import { updateUser, validateUserAndSession } from '/lib/data-utils.ts'; +import { getSubscribedUsers } from '/lib/providers/paddle.ts'; +import { PADDLE_MONTHLY_PLAN_ID } from '/lib/utils.ts'; + +export async function pageAction(request: Request) { + if (request.method !== 'POST') { + return new Response('Not Implemented', { status: 501 }); + } + + const { session_id, user_id }: { session_id: string; user_id: string } = await request.json(); + + if (!session_id || !user_id) { + return new Response('Bad Request', { status: 400 }); + } + + const { user } = await validateUserAndSession(user_id, session_id); + + const subscribedUsers = await getSubscribedUsers(); + + const subscribedUser = subscribedUsers.find((paddleUser) => paddleUser.user_email === user.email); + + if (subscribedUser) { + user.subscription.isMonthly = subscribedUser.plan_id === PADDLE_MONTHLY_PLAN_ID; + user.subscription.updated_at = new Date().toISOString(); + user.subscription.expires_at = new Date(subscribedUser.next_payment.date).toISOString(); + user.subscription.external.paddle = { + user_id: subscribedUser.user_id.toString(), + subscription_id: subscribedUser.subscription_id.toString(), + update_url: subscribedUser.update_url, + cancel_url: subscribedUser.cancel_url, + }; + user.status = 'active'; + + await updateUser(user); + } + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +export function pageContent() { + return new Response('Not Implemented', { status: 501 }); +} diff --git a/pages/api/user.ts b/pages/api/user.ts new file mode 100644 index 0000000..4bcff5d --- /dev/null +++ b/pages/api/user.ts @@ -0,0 +1,177 @@ +import { + createSession, + createUser, + createVerificationCode, + deleteUser, + getSessionById, + getUserByEmail, + getUserById, + updateSession, + updateUser, + validateUserAndSession, + validateVerificationCode, +} from '/lib/data-utils.ts'; +import { EncryptedData } from '/lib/types.ts'; +import { + sendUpdateEmailInPaddleEmail, + sendVerifyDeleteAccountEmail, + sendVerifyUpdateEmailEmail, + sendVerifyUpdatePasswordEmail, +} from '/lib/providers/postmark.ts'; + +async function createUserAction(request: Request) { + const { email, encrypted_key_pair }: { email: string; encrypted_key_pair: EncryptedData } = await request.json(); + + if (!email || !encrypted_key_pair) { + return new Response('Bad Request', { status: 400 }); + } + + const existingUserByEmail = await getUserByEmail(email); + + if (existingUserByEmail) { + return new Response('Bad Request', { status: 400 }); + } + + const user = await createUser(email, encrypted_key_pair); + + if (!user) { + return new Response('Bad Request', { status: 400 }); + } + + const sessionId = await createSession(user); + + if (!sessionId) { + return new Response('Bad Request', { status: 400 }); + } + + return new Response(JSON.stringify({ user, session_id: sessionId }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +async function updateUserAction(request: Request) { + const { user_id, session_id, email, encrypted_key_pair, code }: { + user_id: string; + session_id: string; + email?: string; + encrypted_key_pair?: EncryptedData; + code?: string; + } = await request.json(); + + if (!user_id || !session_id) { + return new Response('Bad Request', { status: 400 }); + } + + if (!email && !encrypted_key_pair) { + return new Response('Bad Request', { status: 400 }); + } + + const { user, session } = await validateUserAndSession(user_id, session_id); + + if (!code) { + if (email) { + const existingUserByEmail = await getUserByEmail(email); + + if (existingUserByEmail) { + return new Response('Bad Request', { status: 400 }); + } + } + + const verificationCode = await createVerificationCode(user, session, 'user-update'); + + if (email) { + await sendVerifyUpdateEmailEmail(user.email, verificationCode); + } + if (encrypted_key_pair) { + await sendVerifyUpdatePasswordEmail(user.email, verificationCode); + } + } else { + await validateVerificationCode(user, session, code, 'user-update'); + + const oldEmail = user.email; + + if (email) { + user.email = email; + } + + if (encrypted_key_pair) { + user.encrypted_key_pair = encrypted_key_pair; + } + + await updateUser(user); + + if (email && user.subscription.external.paddle?.user_id && email !== oldEmail) { + await sendUpdateEmailInPaddleEmail(oldEmail, email); + } + } + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +async function deleteUserAction(request: Request) { + const { user_id, session_id, code }: { user_id: string; session_id: string; code?: string } = await request.json(); + + if (!user_id || !session_id) { + return new Response('Bad Request', { status: 400 }); + } + + const { user, session } = await validateUserAndSession(user_id, session_id); + + if (!code) { + const verificationCode = await createVerificationCode(user, session, 'user-delete'); + + await sendVerifyDeleteAccountEmail(user.email, verificationCode); + } else { + await validateVerificationCode(user, session, code, 'user-delete'); + + await deleteUser(user.id); + } + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); +} + +export function pageAction(request: Request) { + switch (request.method) { + case 'POST': + return createUserAction(request); + case 'PATCH': + return updateUserAction(request); + case 'DELETE': + return deleteUserAction(request); + } + + return new Response('Not Implemented', { status: 501 }); +} + +export async function pageContent(request: Request) { + const urlSearchParams = new URL(request.url).searchParams; + const sessionId = urlSearchParams.get('session_id'); + const userId = urlSearchParams.get('user_id'); + const email = urlSearchParams.get('email'); + + if (!sessionId || !userId || !email) { + return new Response('Bad Request', { status: 400 }); + } + + const user = await getUserById(userId); + + if (!user || user.email !== email) { + return new Response('Not Found', { status: 404 }); + } + + const session = await getSessionById(sessionId); + + if (!session) { + return new Response('Not Found', { status: 404 }); + } + + session.last_seen_at = new Date(); + + await updateSession(session); + + return new Response(JSON.stringify(user), { headers: { 'Content-Type': 'application/json; charset=utf-8' } }); +} diff --git a/pages/billing.ts b/pages/billing.ts index e1c0c57..00f9ca5 100644 --- a/pages/billing.ts +++ b/pages/billing.ts @@ -1,4 +1,5 @@ -import { html, PageContentResult } from '../lib/utils.ts'; +import { html, PageContentResult } from '/lib/utils.ts'; +import verificationCodeModal from '/components/modals/verification-code.ts'; export function pageAction() { return new Response('Not Implemented', { status: 501 }); @@ -70,158 +71,9 @@ export function pageContent() { - + `; return { diff --git a/pages/index.ts b/pages/index.ts index 5f6ebf9..ad5223f 100644 --- a/pages/index.ts +++ b/pages/index.ts @@ -1,4 +1,5 @@ -import { html, PageContentResult } from '../lib/utils.ts'; +import { helpEmail, html, PageContentResult } from '/lib/utils.ts'; +import verificationCodeModal from '/components/modals/verification-code.ts'; export function pageAction() { return new Response('Not Implemented', { status: 501 }); @@ -61,7 +62,7 @@ export function pageContent() {

Need help?

- If you're having any issues or have any questions, please reach out. + If you're having any issues or have any questions, please reach out.

@@ -216,7 +217,9 @@ export function pageContent() { - + ${verificationCodeModal()} + + `; return { diff --git a/pages/pricing.ts b/pages/pricing.ts index 7b55851..5b68482 100644 --- a/pages/pricing.ts +++ b/pages/pricing.ts @@ -1,4 +1,4 @@ -import { html, PageContentResult } from '../lib/utils.ts'; +import { html, PageContentResult } from '/lib/utils.ts'; export function pageAction() { return new Response('Not Implemented', { status: 501 }); @@ -10,7 +10,7 @@ export function pageContent() {

Pricing

Pricing is simple.

-

You have a 30-day free trial (no credit card required), and at the end, you can pay €18 / year, or €2 / month, no limits.

+

You have a 30-day free trial (no credit card required), and at the end, you can pay €18 or $20 / year, or €2 or $3 / month, no limits.

Signup or Login first

@@ -37,110 +37,17 @@ export function pageContent() {

or
- + `; return { diff --git a/pages/settings.ts b/pages/settings.ts index f8230bb..2f6075d 100644 --- a/pages/settings.ts +++ b/pages/settings.ts @@ -1,4 +1,5 @@ -import { html, PageContentResult } from '../lib/utils.ts'; +import { html, PageContentResult } from '/lib/utils.ts'; +import verificationCodeModal from '/components/modals/verification-code.ts'; export function pageAction() { return new Response('Not Implemented', { status: 501 }); @@ -14,7 +15,7 @@ export function pageContent() {