commit 133944922c72c6c7b1fc90df04c3b7820601e6b7 Author: Mark Bailey Date: Mon Nov 11 13:48:04 2024 -0500 chore: initial commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..8ea14fb --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/" + delay = 1000 + exclude_dir = ["public", "data", "tmp"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = true + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31c1742 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/** +dist/**/* +ptpp +debug +log/* +database/backup/* +project-files/.obsidian/** +**/output.css +tmp/* +data/* +data/database.db +.idea \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9de213a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +ARG GO_VERSION=1.23.2 + +################################################################################ +# Stage: builder ############################################################### +################################################################################ +FROM golang:${GO_VERSION}-alpine AS builder + +WORKDIR /usr/src/app + +RUN apk add npm + +COPY go.mod go.sum ./ +RUN go mod download && go mod verify + +COPY . . + +RUN npm i -g tailwindcss +RUN npm i -D @tailwindcss/forms +RUN tailwindcss -i ./public/assets/css/index.css -o ./public/assets/css/output.css +RUN rm ./public/assets/css/index.css + +RUN go build -v -o /ptpp-build ./cmd + +################################################################################ +# Stage: runner ################################################################ +################################################################################ +FROM alpine:latest AS runner + +WORKDIR /app + +ENV PATH="/root/go/bin:/app:${PATH}" + +RUN apk add go + +COPY --from=builder /ptpp-build . +COPY --from=builder /usr/src/app/public ./public +COPY --from=builder /usr/src/app/migrations ./migrations + +CMD ["ptpp-build"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f14cf3 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Golang Site Template + +## What this project isn't + +- A library +- A framework + +## What is this project? + +- A template, a starting point. An inspiration, maybe? + +This project is a demo, of sorts, really. It is the base for my next side project, +which you can find at [markbailey.dev](https://markbailey.dev). This project is a +culmination of many, many hours poring over Go documentation, others' Go code, +and questions I wanted answers to, left over from previous experimentation with a +popular web framework for Go. This project is a full stack application. + +This is a template for a full stack web application written in Golang. +It is fully-featured, and includes a number of tools to bootstrap a project on top +of what is already here. This project is a work in progress, it will be updated +and maintained for the foreseeable future. This is a labor of love, learning, +and a desire to build software that intrigues me outside of my day-to-day work. + +I chose Go for this project because I wanted to explore the language, and the concepts +it lends itself well to. You will notice that this project contains a number of +features and patterns found in large scale projects. This is intentional, as in +my day-to-day work I write fullstack PHP in the Symfony framework. I wanted to +mirror the patterns I use at work in this project, and it has proven to be a +huge boost in my understanding of both Go and the patterns, tools, and paradigms +I use at work. Much of this project has been influenced by the advice of my +incredible mentor, friend, and boss Andy, and my guru level senior developer. +I have accumulated here the best practices that I have learned from them, from +my own experience, and from the tenacious push in the Go community to write +clean, idiomatic, and readable code. + +It contains these features and more: + +- Middleware (Fully customizable, custom) +- Routing and sub-routing +- In-memory sessions +- Templating +- Database interaction + This one is special, because the project uses an SQLite3 database for + local development, and a PostgreSQL database for production. +- Password authentication +- Project management via the `bin/app` script + +This site is currently powering my personal site, which is self hosted on a VPS. +One of my favorite features of this project is the low number of dependencies. Check +out the following excerpt from `go.mod` (As of 2024-11-11). + +```go +require ( + github.com/a-h/templ v0.2.793 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/lib/pq v1.10.9 + github.com/mattn/go-sqlite3 v1.14.24 + golang.org/x/crypto v0.27.0 +) +``` + +There are only seven dependencies in this project, two of them being +just database drivers. There are also the "hidden" dependencies of sqlc, and TailwindCSS. +I like to think there are actually only five, if we don't count the obligatory +database drivers. + +## Project Statistics + +![project statistics](./project-files/11092024-ptpp-stats.png) + +## Setup + +1. Install Go + - Arch: `sudo pacman -S go` + - Other OS: from your package manager or visit [https://go.dev/doc/install](https://go.dev/doc/install) +2. Select a useable IDE: + - GoLand (JetBrains) + - Neovim + - Install `gopls` LSP using Mason and enable it in your `lspconfig` + - Set up formatting with `gofumpt` and `goimports` + - VSCode + - I don't use it, and I wouldn't recommend it. But you do you, boo :) +3. Clone project: `git clone https://git.markbailey.dev/cerbervs/ptpp.git` +4. Run: `make first-install` +5. Access the development site at + [http://localhost:8080](http://localhost:8080) or access the live site at + +## Project Management Through `bin/app` + +`bin/app` is a script that manages the project from top to bottom. +Please consult the help menu for more information: `bin/app help` + +When starting from a fresh clone of the project, run +`bin/app first-install` to set up the project. This will install several +binaries, and the required node packages. You may be required to enter your +password. + +## Git Conventions + +### Making Branches + +Branches should be prefixed with one of the following + +- `feat/` +- `fix/` +- `refactor/` +- `test/` + +Followed by a short description of the proposed changeset `separated-by-hyphens` + +### Committing Code + +This project uses +[Conventional Commits (Angular)](https://www.conventionalcommits.org/en/v1.0.0/#specification), +and in the future, +[Commitlint](https://github.com/conventional-changelog/commitlint) diff --git a/bin/app b/bin/app new file mode 100755 index 0000000..829cf4d --- /dev/null +++ b/bin/app @@ -0,0 +1,394 @@ +#!/bin/sh + +GREEN='\033[0;32m' +ORANGE='\033[0;33m' +NO_COLOR='\033[0m' +BRIGHT_CYAN='\033[0;96m' +BRIGHT_PURPLE='\033[0;95m' + +__check_go() { + if ! command -v go &>/dev/null; then + echo -e "\nGo is not installed. Please install Go." + echo -e "\nhttps://golang.org/doc/install" + _cmd_missing=true + _cmd_missing_list="${_cmd_missing_list}go " + fi +} + +__check_node() { + if ! command -v npm &>/dev/null; then + echo -e "\nNode is not installed. Please install Node." + echo -e "\nhttps://nodejs.org/en/download/" + _cmd_missing=true + _cmd_missing_list="${_cmd_missing_list}npm " + fi +} + +__check_dlv() { + if ! command -v dlv &>/dev/null; then + echo -e "\nDLV is not installed. Please install DLV." + echo -e "\nbin/app install-tools" + _cmd_missing=true + _cmd_missing_list="${_cmd_missing_list}dlv " + fi +} + +__check_sqlc() { + if ! command -v sqlc &>/dev/null; then + echo -e "\nSQLC is not installed. Please install SQLC." + echo -e "\nbin/app install-tools" + _cmd_missing=true + _cmd_missing_list="${_cmd_missing_list}sqlc " + fi +} + +__check_goose() { + if ! command -v goose &>/dev/null; then + echo -e "\nGoose is not installed. Please install Goose." + echo -e "\nbin/app install-tools" + _cmd_missing=true + _cmd_missing_list="${_cmd_missing_list}goose " + fi +} + +__check_air() { + if ! command -v air &>/dev/null; then + echo -e "\nAir is not installed. Please install Air." + echo -e "\nbin/app install-tools" + _cmd_missing=true + _cmd_missing_list="${_cmd_missing_list}air " + fi +} + +__check_templ() { + if ! command -v templ &>/dev/null; then + echo -e "\nTempl is not installed. Please install Templ." + echo -e "\nbin/app install-tools" + _cmd_missing=true + _cmd_missing_list="${_cmd_missing_list}templ " + fi +} + +__check_tailwind() { + if ! command -v tailwindcss &>/dev/null; then + echo -e "\nTailwind is not installed. Please install Tailwind." + echo -e "\nnpm i -g tailwindcss && npm i -D @tailwindcss/forms" + _cmd_missing=true + _cmd_missing_list="${_cmd_missing_list}tailwindcss " + fi +} + +__check_missing() { + export _cmd_missing=false + export _cmd_missing_list= + __check_go + __check_node + __check_dlv + __check_sqlc + __check_goose + __check_air + __check_templ + __check_tailwind + if $_cmd_missing; then + echo -e "\nMissing commands: ${_cmd_missing_list}" + export _cmd_missing=false + __cmd_missing_list= + exit 1 + fi +} + +__install_deps() { + echo ================================================================================ + echo = Install dependencies ========================================================= + echo ================================================================================ + go mod download && go mod verify + echo -e "\n" +} +INSTALL_DEPS_match=install-deps + +__install_project_tools() { + export _cmd_missing=false + __check_go + if $_cmd_missing; then + exit 1 + fi + go install github.com/a-h/templ/cmd/templ@latest + go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest + go install github.com/pressly/goose/v3/cmd/goose@latest + go install github.com/air-verse/air@latest + go install github.com/go-delve/delve/cmd/dlv@latest +} +INSTALL_PROJECT_TOOLS_match=install-tools + +__clean() { + echo ================================================================================ + echo = Cleaning environment ========================================================= + echo ================================================================================ + echo -e "\n" + find . -name '*_templ.go' -delete + rm -rf ./ptpp ./debug ./public/assets/css/output.css ./dist/* ./models/* + echo -e "\n" +} +CLEAN_match=clean + +__init_project() { + echo ================================================================================ + echo = First install ================================================================ + echo ================================================================================ + mkdir -p data && mkdir -p log + if ! [ -f "data/database.db" ]; then + touch data/database.db + fi + + _cmd_missing=false + __check_go + if $_cmd_missing; then + exit 1 + fi + + __install_project_tools + sudo -S npm install + echo -e "\n" +} +INIT_PROJECT_match=init-project + +__first_install_macro() { + _and_run=${1:-} + if ! [ -z ${1+x} ]; then + shift + fi + + __init_project + __install_deps + __check_missing + __clean + __generate_macro + __migrate --all + case "$_and_run" in + --and-run) __run "$@" ;; + *) ;; + esac +} +FIRST_INSTALL_MACRO_match=first-install + +__build() { + _cmd_missing=false + __check_go + if $_cmd_missing; then + exit 1 + fi + echo ================================================================================ + echo = Building ===================================================================== + echo ================================================================================ + go build -v -o ptpp ./cmd + echo -e "\n" +} +BUILD_match=build + +__run() { + _case=${1:-} + case "$1" in + --reload) _reload=true ;; + "") _reload=false ;; + *) + echo -e "Invalid flag $_case\n" + __help + ;; + esac + + __build + + if $_reload; then + _cmd_missing=false + __check_air + if $_cmd_missing; then + exit 1 + fi + + air + echo -e "\n" + else + echo ================================================================================ + echo = Running ====================================================================== + echo ================================================================================ + echo -e "\n" + ./ptpp + fi +} +RUN_match=run + +__build_debug() { + _cmd_missing=false + __check_go + __check_dlv + if $_cmd_missing; then + exit 1 + fi + echo ================================================================================ + echo = Building debug ============================================================== + echo ================================================================================ + go build -gcflags "all=-N -l" -o debug ./cmd/main.go + echo -e "\n" +} +BUILD_DEBUG_match=build-debug + +__run_debug() { + _cmd_missing=false + __check_dlv + if $_cmd_missing; then + exit 1 + fi + + __build_debug + echo ================================================================================ + echo = Running debug =============================================================== + echo ================================================================================ + dlv -l 127.0.0.1:2345 --headless --api-version=2 --accept-multiclient exec ./debug + echo -e "\n" +} +RUN_DEBUG_match=run-debug + +__migrate() { + _cmd_missing=false + __check_goose + if $_cmd_missing; then + exit 1 + fi + + _env=${1:-dev} + if ! [ -z ${1+x} ]; then + shift + fi + _direction=${1:-up} + case "$_env" in + --all) + GOOSE_DRIVER=postgres GOOSE_MIGRATION_DIR="./migrations/postgres" goose $_direction + GOOSE_DRIVER=sqlite GOOSE_MIGRATION_DIR="./migrations/sqlite" goose $_direction + ;; + dev) + GOOSE_DRIVER=sqlite GOOSE_MIGRATION_DIR="./migrations/sqlite" goose $_direction + ;; + prod) + GOOSE_DRIVER=postgres GOOSE_MIGRATION_DIR="./migrations/postgres" goose $_direction + ;; + esac + + echo ================================================================================ + echo = Migrating ==================================================================== + echo ================================================================================ + + echo -e "\n" +} +MIGRATE_match=migrate + +__sqlc() { + _cmd_missing=false + __check_sqlc + if $_cmd_missing; then + exit 1 + fi + echo ================================================================================ + echo = Generating SQLC ============================================================== + echo ================================================================================ + sqlc generate + echo -e "\n" +} + +__templ() { + echo ================================================================================ + echo = Generating templates ========================================================= + echo ================================================================================ + templ generate + echo -e "\n" +} +TEMPL_match=templ + +__tailwind() { + _cmd_missing=false + __check_tailwind + if $_cmd_missing; then + exit 1 + fi + echo ================================================================================ + echo = Generating Tailwind ========================================================== + echo ================================================================================ + tailwindcss build -i ./public/assets/css/index.css -o ./public/assets/css/output.css + echo -e "\n" +} +TAILWIND_match=tailwind + +__generate_macro() { + _base=${1:---all} + case "$_base" in + --all) + __templ + __sqlc + __tailwind + ;; + --templ) __templ ;; + --sqlc) __sqlc ;; + --tailwind) __tailwind ;; + *) + echo -e "Invalid flag $_base\n" + __help + ;; + esac +} +GENERATE_MACRO_match=generate + +__help() { + echo -e "Usage: $0 " + echo -e "Commands:" + echo -e " ${GREEN}${FIRST_INSTALL_MACRO_match}${NO_COLOR}: ${ORANGE}First install macro${NO_COLOR}" + echo -e " ${BRIGHT_PURPLE}? ${GREEN}--and-run${NO_COLOR}: ${ORANGE}Run project after first install${NO_COLOR}" + echo -e " ${BRIGHT_PURPLE}? ${GREEN}--reload${NO_COLOR}: ${ORANGE}Run project with reload${NO_COLOR}" + echo -e " ${GREEN}${CLEAN_match}${NO_COLOR}: ${ORANGE}Clean environment${NO_COLOR}" + echo -e " ${GREEN}${INSTALL_DEPS_match}${NO_COLOR}: ${ORANGE}Install dependencies${NO_COLOR}" + echo -e " ${GREEN}${INSTALL_PROJECT_TOOLS_match}${NO_COLOR}: ${ORANGE}Install project tools${NO_COLOR}" + echo -e " ${GREEN}${BUILD_match}${NO_COLOR}: ${ORANGE}Build project${NO_COLOR}" + echo -e " ${GREEN}${RUN_match}${NO_COLOR}: ${ORANGE}Run project${NO_COLOR}" + echo -e " ${BRIGHT_PURPLE}? ${GREEN}--reload${NO_COLOR}: ${ORANGE}Run project with reload${NO_COLOR}" + echo -e " ${GREEN}${GENERATE_MACRO_match}${NO_COLOR}: ${ORANGE}Run code generation${NO_COLOR}" + echo -e " ${BRIGHT_PURPLE}* ${GREEN}--all${NO_COLOR}: ${ORANGE}Generate all${NO_COLOR}" + echo -e " ${GREEN}--tailwind${NO_COLOR}: ${ORANGE}Generate Tailwind CSS${NO_COLOR}" + echo -e " ${GREEN}--templ${NO_COLOR}: ${ORANGE}Generate templates${NO_COLOR}" + echo -e " ${GREEN}--sqlc${NO_COLOR}: ${ORANGE}Generate ORM${NO_COLOR}" + echo -e " ${GREEN}${MIGRATE_match}${NO_COLOR}: ${ORANGE}Migrate database${NO_COLOR}" + echo -e " ${GREEN}--all${NO_COLOR}: ${ORANGE}Migrate all databases${NO_COLOR}" + echo -e " ${BRIGHT_PURPLE}* ${GREEN}dev${NO_COLOR}: ${ORANGE}Migrate development database${NO_COLOR}" + echo -e " ${GREEN}prod${NO_COLOR}: ${ORANGE}Migrate production database${NO_COLOR}" + echo -e " ${BRIGHT_PURPLE}?* ${GREEN}up${NO_COLOR}: ${ORANGE}Migrate up${NO_COLOR}" + echo -e " ${BRIGHT_PURPLE}? ${GREEN}down${NO_COLOR}: ${ORANGE}Migrate down${NO_COLOR}" + echo -e " ${GREEN}${BUILD_DEBUG_match}${NO_COLOR}: ${ORANGE}Build debug${NO_COLOR}" + echo -e " ${GREEN}${RUN_DEBUG_match}${NO_COLOR}: ${ORANGE}Run debug${NO_COLOR}" + echo -e "\n${BRIGHT_PURPLE}* Default\n? Optional${NO_COLOR}" +} + +__main() { + _cmd=${1:-help} + if ! [ -z ${1+x} ]; then + shift + fi + + case "$_cmd" in + help) __help ;; + "$CLEAN_match") __clean ;; + "$INSTALL_DEPS_match") __install_deps ;; + "$BUILD_match") __build ;; + "$RUN_match") __run "$@" ;; + "$MIGRATE_match") __migrate "$@" ;; + "$BUILD_DEBUG_match") __build_debug ;; + "$RUN_DEBUG_match") __run_debug ;; + "$FIRST_INSTALL_MACRO_match") __first_install_macro "$@" ;; + "$GENERATE_MACRO_match") __generate_macro "$@" ;; + "$INSTALL_PROJECT_TOOLS_match") __install_project_tools ;; + *) + echo -e "Invalid command: $_cmd\n" + __help + ;; + esac + + __check_missing +} + +__main "$@" diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..f56ac03 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,10 @@ +package main + +import ( + inf "git.markbailey.dev/cervers/ptpp/infrastructure" + _ "git.markbailey.dev/cervers/ptpp/lib/session/memory" +) + +func main() { + inf.NewServer().Serve() +} diff --git a/database/query/postgres/heartbeat.sql b/database/query/postgres/heartbeat.sql new file mode 100644 index 0000000..57640ed --- /dev/null +++ b/database/query/postgres/heartbeat.sql @@ -0,0 +1,16 @@ +-- name: CreateHeartbeat :one +INSERT INTO "heartbeat" + ("user", created_at, ip_addr, auth_token) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: FindHeartbeatByID :one +SELECT * +FROM "heartbeat" +WHERE id = $1; + +-- name: FindHeartbeatByUser :many +SELECT * +FROM "heartbeat" +WHERE "user" = $1 +ORDER BY created_at DESC; \ No newline at end of file diff --git a/database/query/postgres/organization.sql b/database/query/postgres/organization.sql new file mode 100644 index 0000000..4d53e62 --- /dev/null +++ b/database/query/postgres/organization.sql @@ -0,0 +1,13 @@ +-- name: CreateOrganization :one +INSERT INTO "organization" + (name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: FindOrganizationByName :one +SELECT * FROM "organization" +WHERE name = $1; + +-- name: FindOrganizationById :one +SELECT * FROM "organization" +WHERE id = $1; \ No newline at end of file diff --git a/database/query/postgres/user.sql b/database/query/postgres/user.sql new file mode 100644 index 0000000..7f54a7d --- /dev/null +++ b/database/query/postgres/user.sql @@ -0,0 +1,9 @@ +-- name: CreateUser :one +INSERT INTO "user" + (username, password, name, email, auth_token, authorized, admin, organization) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: FindUserByUsername :one +SELECT * FROM "user" +WHERE username = $1; \ No newline at end of file diff --git a/database/query/sqlite/heartbeat.sql b/database/query/sqlite/heartbeat.sql new file mode 100644 index 0000000..268e60e --- /dev/null +++ b/database/query/sqlite/heartbeat.sql @@ -0,0 +1,16 @@ +-- name: CreateHeartbeat :one +INSERT INTO heartbeat + ("user", created_at, ip_addr, auth_token) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: FindHeartbeatByID :one +SELECT * +FROM heartbeat +WHERE id = ?; + +-- name: FindHeartbeatByUser :many +SELECT * +FROM heartbeat +WHERE "user" = ? +ORDER BY created_at DESC; \ No newline at end of file diff --git a/database/query/sqlite/organization.sql b/database/query/sqlite/organization.sql new file mode 100644 index 0000000..8f8d998 --- /dev/null +++ b/database/query/sqlite/organization.sql @@ -0,0 +1,13 @@ +-- name: CreateOrganization :one +INSERT INTO organization + (name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: FindOrganizationByName :one +SELECT * FROM organization +WHERE name = ?; + +-- name: FindOrganizationById :one +SELECT * FROM organization +WHERE id = ?; \ No newline at end of file diff --git a/database/query/sqlite/user.sql b/database/query/sqlite/user.sql new file mode 100644 index 0000000..4032057 --- /dev/null +++ b/database/query/sqlite/user.sql @@ -0,0 +1,9 @@ +-- name: CreateUser :one +INSERT INTO "user" + (username, password, name, email, auth_token, authorized, admin, organization) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: FindUserByUsername :one +SELECT * FROM "user" +WHERE username = ?; \ No newline at end of file diff --git a/database/schema/postgres.sql b/database/schema/postgres.sql new file mode 100644 index 0000000..7e3ab3e --- /dev/null +++ b/database/schema/postgres.sql @@ -0,0 +1,72 @@ +DROP TABLE IF EXISTS "heartbeat"; +DROP SEQUENCE IF EXISTS heartbeat_id_seq; +CREATE SEQUENCE heartbeat_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."heartbeat" +( + "id" integer DEFAULT nextval('heartbeat_id_seq') NOT NULL, + "user" integer NOT NULL, + "created_at" timestamptz NOT NULL, + "ip_addr" text NOT NULL, + "auth_token" text NOT NULL, + CONSTRAINT "heartbeat_pkey" PRIMARY KEY ("id") +) WITH (oids = false); + +CREATE INDEX "heartbeats_user_idx" ON "public"."heartbeat" USING btree ("user"); + + +DROP TABLE IF EXISTS "organization"; +DROP SEQUENCE IF EXISTS organization_id_seq; +CREATE SEQUENCE organization_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."organization" +( + "id" integer DEFAULT nextval('organization_id_seq') NOT NULL, + "name" text NOT NULL, + "owner_name" text NOT NULL, + "owner_phone" text NOT NULL, + "owner_email" text NOT NULL, + "created_at" timestamptz NOT NULL, + "deleted_at" timestamptz NULL DEFAULT NULL, + "authorized" integer NOT NULL, + "auth_token" text NOT NULL, + CONSTRAINT "organization_name_key" UNIQUE ("name"), + CONSTRAINT "organization_pkey" PRIMARY KEY ("id") +) WITH (oids = false); + +CREATE INDEX "organizations_name_idx" ON "public"."organization" USING btree ("name"); + +CREATE INDEX "organizations_owner_email_idx" ON "public"."organization" USING btree ("owner_email"); + +CREATE INDEX "organizations_owner_phone_idx" ON "public"."organization" USING btree ("owner_phone"); + +DROP TABLE IF EXISTS "user"; +DROP SEQUENCE IF EXISTS user_id_seq; +CREATE SEQUENCE user_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."user" +( + "id" integer DEFAULT nextval('user_id_seq') NOT NULL, + "username" text NOT NULL, + "password" text NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "auth_token" text NOT NULL, + "authorized" integer NOT NULL, + "admin" integer NOT NULL, + "organization" integer NOT NULL, + CONSTRAINT "user_email_key" UNIQUE ("email"), + CONSTRAINT "user_pkey" PRIMARY KEY ("id"), + CONSTRAINT "user_username_key" UNIQUE ("username") +) WITH (oids = false); + +CREATE INDEX "user_email_idx" ON "public"."user" USING btree ("email"); + +CREATE INDEX "user_username_idx" ON "public"."user" USING btree ("username"); + + +ALTER TABLE ONLY "public"."heartbeat" + ADD CONSTRAINT "heartbeat_user_fkey" FOREIGN KEY ("user") REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE; + +ALTER TABLE ONLY "public"."user" + ADD CONSTRAINT "user_organization_fkey" FOREIGN KEY (organization) REFERENCES organization (id) ON DELETE CASCADE NOT DEFERRABLE; diff --git a/database/schema/sqlite.sql b/database/schema/sqlite.sql new file mode 100644 index 0000000..085aa71 --- /dev/null +++ b/database/schema/sqlite.sql @@ -0,0 +1,45 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE "user" +( + id INTEGER PRIMARY KEY NOT NULL, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + auth_token TEXT NOT NULL, + authorized INTEGER NOT NULL, + admin INTEGER NOT NULL, + organization INTEGER NOT NULL, + FOREIGN KEY (organization) REFERENCES organization (id) ON UPDATE NO ACTION ON DELETE CASCADE +); + +CREATE TABLE heartbeat +( + id INTEGER PRIMARY KEY NOT NULL, + "user" INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ip_addr TEXT NOT NULL, + auth_token TEXT NOT NULL, + FOREIGN KEY ("user") REFERENCES "user" (id) ON UPDATE NO ACTION ON DELETE CASCADE +); + +CREATE TABLE organization +( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE, + owner_name TEXT NOT NULL, + owner_phone TEXT NOT NULL, + owner_email TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME, + authorized INTEGER NOT NULL, + auth_token TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS user_username ON "user" (username); +CREATE INDEX IF NOT EXISTS user_email ON "user" (email); +CREATE INDEX IF NOT EXISTS heartbeats_user ON heartbeat ("user"); +CREATE INDEX IF NOT EXISTS organizations_name ON organization (name); +CREATE INDEX IF NOT EXISTS organizations_owner_phone ON organization (owner_phone); +CREATE INDEX IF NOT EXISTS organizations_owner_email ON organization (owner_email); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7f37465 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + db: + image: postgres:latest + restart: always + environment: + POSTGRES_USER: ${PG_USER} + POSTGRES_PASSWORD: ${PG_PASS} + POSTGRES_DB: application + ports: + - 5432:5432 + volumes: + - ${PG_DATA_DIR}:/var/lib/postgresql/data + adminer: + image: adminer + restart: always + ports: + - 8081:8080 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f8197c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module git.markbailey.dev/cervers/ptpp + +go 1.23.2 + +require ( + github.com/a-h/templ v0.2.793 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/lib/pq v1.10.9 + github.com/mattn/go-sqlite3 v1.14.24 + golang.org/x/crypto v0.27.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1195a43 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= +github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= diff --git a/handlers/admin/index.go b/handlers/admin/index.go new file mode 100644 index 0000000..3efde87 --- /dev/null +++ b/handlers/admin/index.go @@ -0,0 +1,50 @@ +package admin + +import ( + "context" + "errors" + "git.markbailey.dev/cervers/ptpp/view/layout" + "net/http" + + "git.markbailey.dev/cervers/ptpp/lib/logger" + "git.markbailey.dev/cervers/ptpp/lib/session" + "git.markbailey.dev/cervers/ptpp/view/admin" +) + +type IndexHandler struct { + logger logger.ILogger + session session.IManager +} + +func NewAdminIndexHandler(l logger.ILogger, s session.IManager) *IndexHandler { + return &IndexHandler{ + logger: l, + session: s, + } +} + +func (h IndexHandler) Index(w http.ResponseWriter, r *http.Request) error { + if r.URL.Path != "/" { + w.WriteHeader(http.StatusNotFound) + err := layout.NotFound().Render(context.Background(), w) + if err != nil { + h.logger.Error(h.logger.Wrap(err, "Error rendering 404 page")) + return err + } + return nil + } + + username, ok := r.Context().Value("username").(string) + if !ok { + err := errors.New("cannot decode request context: for key \"username\"") + h.logger.Error(h.logger.Wrap(err, "Error decoding request context")) + return err + } + + if err := admin.Index(username).Render(context.Background(), w); err != nil { + h.logger.Error(h.logger.Wrap(err, "Error rendering admin index page")) + return err + } + + return nil +} diff --git a/handlers/homepage.go b/handlers/homepage.go new file mode 100644 index 0000000..8eed3f7 --- /dev/null +++ b/handlers/homepage.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "context" + "git.markbailey.dev/cervers/ptpp/view/layout" + "net/http" + "os" + + "git.markbailey.dev/cervers/ptpp/lib/logger" + "git.markbailey.dev/cervers/ptpp/view/homepage" +) + +type HomePageHandler struct { + logger logger.ILogger +} + +func NewHomePageHandler(l logger.ILogger) *HomePageHandler { + return &HomePageHandler{ + logger: l, + } +} + +func (h HomePageHandler) Homepage(w http.ResponseWriter, r *http.Request) error { + if r.URL.Path != "/" { + w.WriteHeader(http.StatusNotFound) + err := layout.NotFound().Render(context.Background(), w) + if err != nil { + h.logger.Error(h.logger.Wrap(err, "Error rendering 404 page")) + return err + } + return nil + } + + if err := homepage.Homepage(os.Getenv("$HTMX_APP_ENV")).Render(context.Background(), w); err != nil { + h.logger.Error(h.logger.Wrap(err, "Error rendering homepage")) + return err + } + + return nil +} diff --git a/handlers/user.go b/handlers/user.go new file mode 100644 index 0000000..9d6b507 --- /dev/null +++ b/handlers/user.go @@ -0,0 +1,315 @@ +package handlers + +import ( + "context" + "encoding/json" + "git.markbailey.dev/cervers/ptpp/lib/database" + "git.markbailey.dev/cervers/ptpp/lib/database/dto" + "net/http" + "time" + + "git.markbailey.dev/cervers/ptpp/lib/logger" + "git.markbailey.dev/cervers/ptpp/lib/session" + "git.markbailey.dev/cervers/ptpp/util" + "git.markbailey.dev/cervers/ptpp/view/user" +) + +type UserHandler struct { + logger logger.ILogger + session session.IManager + db database.IDB +} + +func NewUserHandler(l logger.ILogger, s session.IManager, db database.IDB) *UserHandler { + return &UserHandler{ + logger: l, + session: s, + db: db, + } +} + +func (u *UserHandler) Populate(w http.ResponseWriter, r *http.Request) error { + existingOrg, err := u.db.Repo().FindOrganizationByName("CerbervsSoft") + if existingOrg != nil && err == nil { + return util.Redirect(w, r, "/signup", http.StatusSeeOther, false) + } + + authToken, err := util.CreateTokenForUser("CerbervsSoft") + if err != nil { + return err + } + + organization := dto.Organization{ + Name: "CerbervsSoft", + OwnerName: "Mark Bailey", + OwnerEmail: "email@provider.com", + OwnerPhone: "+11111111111", + Authorized: 1, + AuthToken: authToken, + CreatedAt: time.Now(), + } + _, err = u.db.Repo().CreateOrganization(organization) + if err != nil { + return u.logDBError(err) + } + + return util.Redirect(w, r, "/signup", http.StatusSeeOther, false) +} + +type UserSignInForm struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (u *UserHandler) SignIn(w http.ResponseWriter, r *http.Request) error { + sess := u.session.SessionStart(w, r) + + if r.Method == http.MethodGet { + username, ok := r.Context().Value("username").(string) + if ok { + if username == "" { + return u.failWithFormError(w, r, "Invalid Username or Password.") + } else { + foundUser, err := u.db.Repo().FindUserByUsername(username) + if foundUser == nil || err != nil { + return u.failWithFormError(w, r, "Invalid Username or Password.") + } + + if foundUser.Admin == 1 { + return util.Redirect(w, r, "/admin/", http.StatusSeeOther, false) + } else { + return util.Redirect(w, r, "/", http.StatusSeeOther, false) + } + } + } + + formError, ok := sess.Get("formError").(string) + if !ok { + formError = "" + } + + if err := user.SignIn(formError).Render(context.Background(), w); err != nil { + u.logger.Error(u.logger.Wrap(err, "Error rendering sign in form")) + return err + } + + return nil + } + + fd := &UserSignInForm{} + + jsonDecoder := json.NewDecoder(r.Body) + if err := jsonDecoder.Decode(&fd); err != nil { + u.logger.Error(u.logger.Wrap(err, "Error decoding JSON")) + return err + } + + foundUser, err := u.db.Repo().FindUserByUsername(fd.Username) + if foundUser == nil || err != nil { + return util.Redirect(w, r, "/signin", http.StatusSeeOther, false) + } + + authenticated, err := util.CheckPassword(fd.Password, foundUser.Password) + if err != nil || !authenticated { + return u.failWithFormError(w, r, "Invalid Username or Password.") + } + + err = sess.Set("username", foundUser.Username) + if err != nil { + return err + } + + token, err := util.CreateTokenForUser(foundUser.Username) + if err != nil { + u.logger.Error(u.logger.Wrap(err, "Error creating token")) + return err + } + + err = sess.Set("token", token) + if err != nil { + return err + } + + authToken, err := util.CreateTokenForUser(foundUser.Username) + if err != nil { + u.logger.Error(err) + return err + } + + _, err = u.db.Repo().CreateHeartbeat(&dto.Heartbeat{ + User: foundUser.Identifier, + CreatedAt: time.Now(), + IpAddr: r.RemoteAddr, + AuthToken: authToken, + }) + if err != nil { + return u.logDBError(err) + } + + jwtToken, err := util.CreateTokenForUser(fd.Username) + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: jwtToken, + Expires: time.Now().Add(time.Hour * 1), + Path: "/", + }) + + err = sess.Delete("formError") + if err != nil { + return err + } + + u.logger.Info(foundUser.Username + " logged in") + if foundUser.Admin == 1 { + return util.Redirect(w, r, "/admin/", http.StatusSeeOther, false) + } else { + return util.Redirect(w, r, "/", http.StatusSeeOther, false) + } +} + +type UserSignUpForm struct { + Name string `json:"name"` + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` + PasswordConfirmation string `json:"password_confirmation"` +} + +func (u *UserHandler) SignUp(w http.ResponseWriter, r *http.Request) error { + handlerSess := u.session.SessionStart(w, r) + uname, ok := handlerSess.Get("username").(string) + if !ok { + uname = "" + } + if uname != "" { + return util.Redirect(w, r, "/", http.StatusSeeOther, false) + } + + if r.Method == http.MethodGet { + if err := user.SignUpForm().Render(context.Background(), w); err != nil { + u.logger.Error(u.logger.Wrap(err, "Error rendering sign up form")) + return err + } + return nil + } + + fd := &UserSignUpForm{} + + jsonDecoder := json.NewDecoder(r.Body) + if err := jsonDecoder.Decode(&fd); err == nil { + if fd.Password != fd.PasswordConfirmation { + if _, err := w.Write([]byte("Passwords don't match")); err != nil { + u.logger.Error(u.logger.Wrap(err, "Error writing response")) + return err + } + return nil + } + + foundUser, err := u.db.Repo().FindUserByUsername(fd.Username) + + if foundUser != nil { + if _, err := w.Write([]byte("Invalid username. Please try another")); err != nil { + u.logger.Error(u.logger.Wrap(err, "Error writing response")) + } + return nil + } + + token, err := util.CreateTokenForUser(fd.Username) + if err != nil { + u.logger.Error(u.logger.Wrap(err, "Error creating token")) + return err + } + + password, err := util.HashPassword(fd.PasswordConfirmation) + if err != nil { + u.logger.Error(u.logger.Wrap(err, "Error hashing password")) + return err + } + + org, err := u.db.Repo().FindOrganizationByName("CerbervsSoft") + if org == nil || err != nil { + return u.logDBError(err) + } + + newUser, err := u.db.Repo().CreateUser(dto.User{ + Username: fd.Username, + Password: password, + Name: fd.Name, + Email: fd.Email, + AuthToken: token, + Authorized: 1, + Admin: 1, + Organization: org.Identifier, + }) + if err != nil { + return u.logDBError(err) + } + + _, err = u.db.Repo().CreateHeartbeat(&dto.Heartbeat{ + User: newUser.Identifier, + CreatedAt: time.Now(), + IpAddr: r.RemoteAddr, + AuthToken: token, + }) + if err != nil { + return u.logDBError(err) + } + + jwtToken, err := util.CreateTokenForUser(fd.Username) + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: jwtToken, + Expires: time.Now().Add(time.Hour * 1), + Path: "/", + }) + + return util.Redirect(w, r, "/signin", http.StatusSeeOther, false) + } + + return nil +} + +func (u *UserHandler) SignOut(w http.ResponseWriter, r *http.Request) error { + sess := u.session.SessionStart(w, r) + + err := sess.Delete("username") + if err != nil { + return err + } + + err = sess.Delete("token") + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: "", + Expires: time.Now().Add(-time.Hour), + Path: "/", + }) + + return util.Redirect(w, r, "/", http.StatusSeeOther, true) +} + +func (u *UserHandler) logDBError(err error) error { + u.db.Error(err) + return err +} + +func (u *UserHandler) failWithFormError(w http.ResponseWriter, r *http.Request, formError string) error { + sess := u.session.SessionStart(w, r) + err := sess.Set("formError", formError) + if err != nil { + return err + } + return util.Redirect(w, r, r.URL.Path, http.StatusSeeOther, false) +} diff --git a/infrastructure/router.go b/infrastructure/router.go new file mode 100644 index 0000000..8b30579 --- /dev/null +++ b/infrastructure/router.go @@ -0,0 +1,79 @@ +package infrastructure + +import ( + "git.markbailey.dev/cervers/ptpp/lib/database" + "net/http" + "sync" + + "git.markbailey.dev/cervers/ptpp/handlers" + "git.markbailey.dev/cervers/ptpp/handlers/admin" + "git.markbailey.dev/cervers/ptpp/lib/logger" + mw "git.markbailey.dev/cervers/ptpp/lib/middleware" + "git.markbailey.dev/cervers/ptpp/lib/session" +) + +var ( + globalSessions *session.Manager + commonRouter *http.ServeMux + adminStack mw.Func + commonStack mw.Func + lock = &sync.Mutex{} + il logger.ILogger +) + +func GetRouter() http.Handler { + commonStack = mw.Compose( + mw.WithLogger, + mw.WithUsername, + ) + + if commonRouter == nil { + lock.Lock() + defer lock.Unlock() + + adminStack = mw.Compose( + mw.WithAuth, + ) + + commonRouter = http.NewServeMux() + adminRouter := http.NewServeMux() + + // Serve static files + fs := http.FileServer(http.Dir("./public/")) + commonRouter.Handle("GET /public/", http.StripPrefix("/public", fs)) + commonRouter.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./public/assets/favicon/favicon.ico") + }) + + // Homepage routes + homepageHandler := handlers.NewHomePageHandler(il) + commonRouter.Handle("/", mw.ErrHandler(homepageHandler.Homepage)) + + if env == "production" { + return commonStack(commonRouter) + } + + // User routes + userHandler := handlers.NewUserHandler(il, globalSessions, database.ChooseDB()) + commonRouter.Handle("GET /signup", mw.ErrHandler(userHandler.SignUp)) + commonRouter.Handle("POST /signup", mw.ErrHandler(userHandler.SignUp)) + commonRouter.Handle("GET /signin", mw.ErrHandler(userHandler.SignIn)) + commonRouter.Handle("POST /signin", mw.ErrHandler(userHandler.SignIn)) + commonRouter.Handle("GET /signout", mw.ErrHandler(userHandler.SignOut)) + commonRouter.Handle("GET /populate", mw.ErrHandler(userHandler.Populate)) + + // Admin routes + adminIndexHandler := admin.NewAdminIndexHandler(il, globalSessions) + adminRouter.Handle("GET /", mw.ErrHandler(adminIndexHandler.Index)) + commonRouter.Handle("/admin/", http.StripPrefix("/admin", adminStack(adminRouter))) + } + + return commonStack(commonRouter) +} + +func init() { + globalSessions, _ = session.NewManager("memory", "ptpp", 3600) + go globalSessions.GC() + + il = logger.NewCompositeLogger() +} diff --git a/infrastructure/server.go b/infrastructure/server.go new file mode 100644 index 0000000..118382b --- /dev/null +++ b/infrastructure/server.go @@ -0,0 +1,64 @@ +package infrastructure + +import ( + "fmt" + "log" + "net/http" + "os" + "strconv" + "time" +) + +type Server struct { + addr string + http.Server + port int +} + +var ( + env = os.Getenv("HTMX_APP_ENV") +) + +func NewServer() *Server { + const ( + addr = "0.0.0.0" + ) + + var port int + + if env == "production" { + port = 8080 + } else { + port = 8080 + } + + return &Server{ + addr, + http.Server{ + Addr: addr + ":" + strconv.Itoa(port), + Handler: GetRouter(), + DisableGeneralOptionsHandler: false, + TLSConfig: nil, + ReadTimeout: 5 * time.Second, + ReadHeaderTimeout: 0, + WriteTimeout: 5 * time.Second, + IdleTimeout: 0, + MaxHeaderBytes: 0, + TLSNextProto: nil, + ConnState: nil, + ErrorLog: nil, + BaseContext: nil, + ConnContext: nil, + }, + port, + } +} + +func (s *Server) Serve() { + fmt.Printf("Starting.\nListening at %s on port %s\n", s.addr, strconv.Itoa(s.port)) + serverError := s.ListenAndServe() + + if serverError != nil { + log.Fatal(serverError) + } +} diff --git a/lib/database/db.go b/lib/database/db.go new file mode 100644 index 0000000..c14dca4 --- /dev/null +++ b/lib/database/db.go @@ -0,0 +1,36 @@ +package database + +import ( + "git.markbailey.dev/cervers/ptpp/lib/database/development" + "git.markbailey.dev/cervers/ptpp/lib/database/production" + "git.markbailey.dev/cervers/ptpp/lib/repository" + "os" + + "git.markbailey.dev/cervers/ptpp/lib/logger" +) + +type IDB interface { + Info(string) + Warn(string) + Error(err error) + Repo() repository.IRepository +} + +func ChooseDB() IDB { + l := logger.NewDBLogger() + if os.Getenv("HTMX_APP_ENV") == "production" { + db, err := production.NewProdDB(l) + if err != nil { + l.Error(l.Wrap(err, "Error creating production database")) + } + + return db + } + + db, err := development.NewTestDB(l) + if err != nil { + l.Error(l.Wrap(err, "Error creating test database")) + } + + return db +} diff --git a/lib/database/development/db.go b/lib/database/development/db.go new file mode 100644 index 0000000..55194a7 --- /dev/null +++ b/lib/database/development/db.go @@ -0,0 +1,59 @@ +package development + +import ( + "database/sql" + "git.markbailey.dev/cervers/ptpp/lib/logger" + "git.markbailey.dev/cervers/ptpp/lib/repository" + "git.markbailey.dev/cervers/ptpp/models/sqlite" + _ "github.com/mattn/go-sqlite3" + "sync" +) + +var ( + tDB *sql.DB + tDBLock = &sync.Mutex{} + TestQueries *sqlite.Queries +) + +type DB struct { + logger logger.ILogger + Repository repository.IRepository +} + +func NewTestDB(l logger.ILogger) (*DB, error) { + if tDB == nil { + tDBLock.Lock() + defer tDBLock.Unlock() + db, err := sql.Open("sqlite3", "data/database.db") + if err != nil { + l.Error(l.Wrap(err, "Error opening database connection")) + return nil, err + } + tDB = db + } + + if TestQueries == nil { + TestQueries = sqlite.New(tDB) + } + + return &DB{ + logger: l, + Repository: &Repo{}, + }, nil +} + +func (d DB) Repo() repository.IRepository { + return d.Repository +} + +func (d DB) Info(msg string) { + d.logger.Info(msg) +} + +func (d DB) Warn(msg string) { + d.logger.Warn(msg) +} + +func (d DB) Error(err error) { + d.logger.Error(d.logger.Wrap(err, "Error in SQLite3 database")) +} diff --git a/lib/database/development/heartbeatrepo.go b/lib/database/development/heartbeatrepo.go new file mode 100644 index 0000000..be276c1 --- /dev/null +++ b/lib/database/development/heartbeatrepo.go @@ -0,0 +1,68 @@ +package development + +import ( + "context" + "git.markbailey.dev/cervers/ptpp/lib/database/dto" + pterror "git.markbailey.dev/cervers/ptpp/lib/error" + "git.markbailey.dev/cervers/ptpp/models/sqlite" +) + +type HeartbeatRepository struct{} + +func (hr HeartbeatRepository) CreateHeartbeat(h *dto.Heartbeat) (*dto.Heartbeat, error) { + ctx := context.Background() + heartbeat, err := TestQueries.CreateHeartbeat(ctx, sqlite.CreateHeartbeatParams{ + User: h.User, + CreatedAt: &h.CreatedAt, + IpAddr: h.IpAddr, + AuthToken: h.AuthToken, + }) + if err != nil { + return nil, pterror.Wrap(err, "Error creating heartbeat") + } + + return &dto.Heartbeat{ + Identifier: heartbeat.Identifier, + User: heartbeat.User, + CreatedAt: *heartbeat.CreatedAt, + IpAddr: heartbeat.IpAddr, + AuthToken: heartbeat.AuthToken, + }, nil +} + +func (hr HeartbeatRepository) FindHeartbeatByID(id int64) (*dto.Heartbeat, error) { + ctx := context.Background() + heartbeat, err := TestQueries.FindHeartbeatByID(ctx, id) + if err != nil { + return nil, pterror.Wrap(err, "Error finding heartbeat by ID") + } + + return &dto.Heartbeat{ + Identifier: heartbeat.Identifier, + User: heartbeat.User, + CreatedAt: *heartbeat.CreatedAt, + IpAddr: heartbeat.IpAddr, + AuthToken: heartbeat.AuthToken, + }, err +} + +func (hr HeartbeatRepository) FindHeartbeatByUser(u dto.User) ([]*dto.Heartbeat, error) { + ctx := context.Background() + heartbeats, err := TestQueries.FindHeartbeatByUser(ctx, u.Identifier) + if err != nil { + return nil, pterror.Wrap(err, "Error finding heartbeats by user") + } + + var ret []*dto.Heartbeat + for _, h := range heartbeats { + ret = append(ret, &dto.Heartbeat{ + Identifier: h.Identifier, + User: h.User, + CreatedAt: *h.CreatedAt, + IpAddr: h.IpAddr, + AuthToken: h.AuthToken, + }) + } + + return ret, nil +} diff --git a/lib/database/development/organizationrepo.go b/lib/database/development/organizationrepo.go new file mode 100644 index 0000000..1916f3c --- /dev/null +++ b/lib/database/development/organizationrepo.go @@ -0,0 +1,79 @@ +package development + +import ( + "context" + "git.markbailey.dev/cervers/ptpp/lib/database/dto" + pterror "git.markbailey.dev/cervers/ptpp/lib/error" + "git.markbailey.dev/cervers/ptpp/models/sqlite" +) + +type OrganizationRepository struct{} + +func (ur OrganizationRepository) CreateOrganization(o dto.Organization) (*dto.Organization, error) { + ctx := context.Background() + organization, err := TestQueries.CreateOrganization(ctx, sqlite.CreateOrganizationParams{ + Name: o.Name, + OwnerName: o.OwnerName, + OwnerPhone: o.OwnerPhone, + OwnerEmail: o.OwnerEmail, + CreatedAt: &o.CreatedAt, + DeletedAt: o.DeletedAt, + Authorized: o.Authorized, + AuthToken: o.AuthToken, + }) + if err != nil { + return nil, pterror.Wrap(err, "Error creating organization") + } + + return &dto.Organization{ + Identifier: organization.Identifier, + Name: organization.Name, + OwnerName: organization.OwnerName, + OwnerPhone: organization.OwnerPhone, + OwnerEmail: organization.OwnerEmail, + CreatedAt: *organization.CreatedAt, + DeletedAt: organization.DeletedAt, + Authorized: organization.Authorized, + AuthToken: organization.AuthToken, + }, nil +} + +func (ur OrganizationRepository) FindOrganizationByName(name string) (*dto.Organization, error) { + ctx := context.Background() + organization, err := TestQueries.FindOrganizationByName(ctx, name) + if err != nil { + return nil, pterror.Wrap(err, "Error finding organization by name") + } + + return &dto.Organization{ + Identifier: organization.Identifier, + Name: organization.Name, + OwnerName: organization.OwnerName, + OwnerPhone: organization.OwnerPhone, + OwnerEmail: organization.OwnerEmail, + CreatedAt: *organization.CreatedAt, + DeletedAt: organization.DeletedAt, + Authorized: organization.Authorized, + AuthToken: organization.AuthToken, + }, nil +} + +func (ur OrganizationRepository) FindOrganizationById(id int64) (*dto.Organization, error) { + ctx := context.Background() + organization, err := TestQueries.FindOrganizationById(ctx, id) + if err != nil { + return nil, pterror.Wrap(err, "Error finding organization by ID") + } + + return &dto.Organization{ + Identifier: organization.Identifier, + Name: organization.Name, + OwnerName: organization.OwnerName, + OwnerPhone: organization.OwnerPhone, + OwnerEmail: organization.OwnerEmail, + CreatedAt: *organization.CreatedAt, + DeletedAt: organization.DeletedAt, + Authorized: organization.Authorized, + AuthToken: organization.AuthToken, + }, nil +} diff --git a/lib/database/development/repo.go b/lib/database/development/repo.go new file mode 100644 index 0000000..f91148f --- /dev/null +++ b/lib/database/development/repo.go @@ -0,0 +1,7 @@ +package development + +type Repo struct { + OrganizationRepository + UserRepository + HeartbeatRepository +} diff --git a/lib/database/development/userrepo.go b/lib/database/development/userrepo.go new file mode 100644 index 0000000..67e6859 --- /dev/null +++ b/lib/database/development/userrepo.go @@ -0,0 +1,59 @@ +package development + +import ( + "context" + "git.markbailey.dev/cervers/ptpp/lib/database/dto" + pterror "git.markbailey.dev/cervers/ptpp/lib/error" + "git.markbailey.dev/cervers/ptpp/models/sqlite" +) + +type UserRepository struct{} + +func (ur UserRepository) CreateUser(u dto.User) (*dto.User, error) { + ctx := context.Background() + user, err := TestQueries.CreateUser(ctx, sqlite.CreateUserParams{ + Username: u.Username, + Password: u.Password, + Name: u.Name, + Email: u.Email, + AuthToken: u.AuthToken, + Authorized: u.Authorized, + Admin: u.Admin, + Organization: u.Organization, + }) + if err != nil { + return nil, pterror.Wrap(err, "Error creating user") + } + + return &dto.User{ + Identifier: user.Identifier, + Username: user.Username, + Password: user.Password, + Name: user.Name, + Email: user.Email, + AuthToken: user.AuthToken, + Authorized: user.Authorized, + Admin: user.Admin, + Organization: user.Organization, + }, nil +} + +func (ur UserRepository) FindUserByUsername(username string) (*dto.User, error) { + ctx := context.Background() + user, err := TestQueries.FindUserByUsername(ctx, username) + if err != nil { + return nil, pterror.Wrap(err, "Error finding user by username") + } + + return &dto.User{ + Identifier: user.Identifier, + Username: user.Username, + Password: user.Password, + Name: user.Name, + Email: user.Email, + AuthToken: user.AuthToken, + Authorized: user.Authorized, + Admin: user.Admin, + Organization: user.Organization, + }, nil +} diff --git a/lib/database/dto/model.go b/lib/database/dto/model.go new file mode 100644 index 0000000..ccd3485 --- /dev/null +++ b/lib/database/dto/model.go @@ -0,0 +1,37 @@ +package dto + +import ( + "time" +) + +type Heartbeat struct { + Identifier int64 + User int64 + CreatedAt time.Time + IpAddr string + AuthToken string +} + +type Organization struct { + Identifier int64 + Name string + OwnerName string + OwnerPhone string + OwnerEmail string + CreatedAt time.Time + DeletedAt *time.Time + Authorized int64 + AuthToken string +} + +type User struct { + Identifier int64 + Username string + Password string + Name string + Email string + AuthToken string + Authorized int64 + Admin int64 + Organization int64 +} diff --git a/lib/database/production/db.go b/lib/database/production/db.go new file mode 100644 index 0000000..e86a041 --- /dev/null +++ b/lib/database/production/db.go @@ -0,0 +1,60 @@ +package production + +import ( + "database/sql" + "git.markbailey.dev/cervers/ptpp/lib/logger" + "git.markbailey.dev/cervers/ptpp/lib/repository" + "git.markbailey.dev/cervers/ptpp/models/postgres" + _ "github.com/lib/pq" + "os" + "sync" +) + +var ( + prodDB *sql.DB + prodDBLock = &sync.Mutex{} + ProdQueries *postgres.Queries +) + +type DB struct { + logger logger.ILogger + Repository repository.IRepository +} + +func NewProdDB(l logger.ILogger) (*DB, error) { + if prodDB == nil { + prodDBLock.Lock() + defer prodDBLock.Unlock() + db, err := sql.Open("postgres", os.Getenv("DB_URL")) + if err != nil { + l.Error(l.Wrap(err, "Error opening database connection")) + return nil, err + } + prodDB = db + } + + if ProdQueries == nil { + ProdQueries = postgres.New(prodDB) + } + + return &DB{ + logger: l, + Repository: &Repo{}, + }, nil +} + +func (d DB) Repo() repository.IRepository { + return d.Repository +} + +func (d DB) Info(msg string) { + d.logger.Info(msg) +} + +func (d DB) Warn(msg string) { + d.logger.Warn(msg) +} + +func (d DB) Error(err error) { + d.logger.Error(d.logger.Wrap(err, "Error in Postgresql database")) +} diff --git a/lib/database/production/heartbeatrepo.go b/lib/database/production/heartbeatrepo.go new file mode 100644 index 0000000..1a20cb1 --- /dev/null +++ b/lib/database/production/heartbeatrepo.go @@ -0,0 +1,70 @@ +package production + +import ( + "context" + "git.markbailey.dev/cervers/ptpp/lib/database/dto" + pterror "git.markbailey.dev/cervers/ptpp/lib/error" + "git.markbailey.dev/cervers/ptpp/models/postgres" +) + +type HeartbeatRepository struct{} + +func (hr HeartbeatRepository) CreateHeartbeat(h *dto.Heartbeat) (*dto.Heartbeat, error) { + ctx := context.Background() + + heartbeat, err := ProdQueries.CreateHeartbeat(ctx, postgres.CreateHeartbeatParams{ + User: h.User, + CreatedAt: h.CreatedAt, + IpAddr: h.IpAddr, + AuthToken: h.AuthToken, + }) + if err != nil { + return nil, pterror.Wrap(err, "Error creating heartbeat") + } + + return &dto.Heartbeat{ + Identifier: heartbeat.Identifier, + User: heartbeat.User, + CreatedAt: heartbeat.CreatedAt, + IpAddr: heartbeat.IpAddr, + AuthToken: heartbeat.AuthToken, + }, nil +} + +func (hr HeartbeatRepository) FindHeartbeatByID(id int64) (*dto.Heartbeat, error) { + ctx := context.Background() + heartbeat, err := ProdQueries.FindHeartbeatByID(ctx, id) + if err != nil { + return nil, pterror.Wrap(err, "Error finding heartbeat by ID") + } + + return &dto.Heartbeat{ + Identifier: heartbeat.Identifier, + User: heartbeat.User, + CreatedAt: heartbeat.CreatedAt, + IpAddr: heartbeat.IpAddr, + AuthToken: heartbeat.AuthToken, + }, err +} + +func (hr HeartbeatRepository) FindHeartbeatByUser(u dto.User) ([]*dto.Heartbeat, error) { + ctx := context.Background() + heartbeat, err := ProdQueries.FindHeartbeatByUser(ctx, u.Identifier) + if err != nil { + return nil, pterror.Wrap(err, "Error finding heartbeat by user") + } + + var ret []*dto.Heartbeat + + for _, h := range heartbeat { + ret = append(ret, &dto.Heartbeat{ + Identifier: h.Identifier, + User: h.User, + CreatedAt: h.CreatedAt, + IpAddr: h.IpAddr, + AuthToken: h.AuthToken, + }) + } + + return ret, nil +} diff --git a/lib/database/production/organizationrepo.go b/lib/database/production/organizationrepo.go new file mode 100644 index 0000000..20651b0 --- /dev/null +++ b/lib/database/production/organizationrepo.go @@ -0,0 +1,80 @@ +package production + +import ( + "context" + "git.markbailey.dev/cervers/ptpp/lib/database/dto" + pterror "git.markbailey.dev/cervers/ptpp/lib/error" + "git.markbailey.dev/cervers/ptpp/models/postgres" +) + +type OrganizationRepository struct{} + +func (or OrganizationRepository) CreateOrganization(o dto.Organization) (*dto.Organization, error) { + ctx := context.Background() + + organization, err := ProdQueries.CreateOrganization(ctx, postgres.CreateOrganizationParams{ + Name: o.Name, + OwnerName: o.OwnerName, + OwnerPhone: o.OwnerPhone, + OwnerEmail: o.OwnerEmail, + CreatedAt: o.CreatedAt, + DeletedAt: o.DeletedAt, + Authorized: o.Authorized, + AuthToken: o.AuthToken, + }) + if err != nil { + return nil, err + } + + return &dto.Organization{ + Identifier: organization.Identifier, + Name: organization.Name, + OwnerName: organization.OwnerName, + OwnerPhone: organization.OwnerPhone, + OwnerEmail: organization.OwnerEmail, + CreatedAt: organization.CreatedAt, + DeletedAt: organization.DeletedAt, + Authorized: organization.Authorized, + AuthToken: organization.AuthToken, + }, nil +} + +func (or OrganizationRepository) FindOrganizationByName(name string) (*dto.Organization, error) { + ctx := context.Background() + organization, err := ProdQueries.FindOrganizationByName(ctx, name) + if err != nil { + return nil, pterror.Wrap(err, "Error finding organization by name") + } + + return &dto.Organization{ + Identifier: organization.Identifier, + Name: organization.Name, + OwnerName: organization.OwnerName, + OwnerPhone: organization.OwnerPhone, + OwnerEmail: organization.OwnerEmail, + CreatedAt: organization.CreatedAt, + DeletedAt: organization.DeletedAt, + Authorized: organization.Authorized, + AuthToken: organization.AuthToken, + }, nil +} + +func (or OrganizationRepository) FindOrganizationById(id int64) (*dto.Organization, error) { + ctx := context.Background() + organization, err := ProdQueries.FindOrganizationById(ctx, id) + if err != nil { + return nil, pterror.Wrap(err, "Error finding organization by ID") + } + + return &dto.Organization{ + Identifier: organization.Identifier, + Name: organization.Name, + OwnerName: organization.OwnerName, + OwnerPhone: organization.OwnerPhone, + OwnerEmail: organization.OwnerEmail, + CreatedAt: organization.CreatedAt, + DeletedAt: organization.DeletedAt, + Authorized: organization.Authorized, + AuthToken: organization.AuthToken, + }, nil +} diff --git a/lib/database/production/repo.go b/lib/database/production/repo.go new file mode 100644 index 0000000..e02fa5a --- /dev/null +++ b/lib/database/production/repo.go @@ -0,0 +1,7 @@ +package production + +type Repo struct { + UserRepository + OrganizationRepository + HeartbeatRepository +} diff --git a/lib/database/production/userrepo.go b/lib/database/production/userrepo.go new file mode 100644 index 0000000..7d7bdc0 --- /dev/null +++ b/lib/database/production/userrepo.go @@ -0,0 +1,59 @@ +package production + +import ( + "context" + "git.markbailey.dev/cervers/ptpp/lib/database/dto" + pterror "git.markbailey.dev/cervers/ptpp/lib/error" + "git.markbailey.dev/cervers/ptpp/models/postgres" +) + +type UserRepository struct{} + +func (ur UserRepository) CreateUser(u dto.User) (*dto.User, error) { + ctx := context.Background() + user, err := ProdQueries.CreateUser(ctx, postgres.CreateUserParams{ + Username: u.Username, + Password: u.Password, + Name: u.Name, + Email: u.Email, + AuthToken: u.AuthToken, + Authorized: u.Authorized, + Admin: u.Admin, + Organization: u.Organization, + }) + if err != nil { + return nil, pterror.Wrap(err, "Error creating user") + } + + return &dto.User{ + Identifier: user.Identifier, + Username: user.Username, + Password: user.Password, + Name: user.Name, + Email: user.Email, + AuthToken: user.AuthToken, + Authorized: user.Authorized, + Admin: user.Admin, + Organization: user.Organization, + }, nil +} + +func (ur UserRepository) FindUserByUsername(username string) (*dto.User, error) { + ctx := context.Background() + user, err := ProdQueries.FindUserByUsername(ctx, username) + if err != nil { + return nil, pterror.Wrap(err, "Error finding user by username") + } + + return &dto.User{ + Identifier: user.Identifier, + Username: user.Username, + Password: user.Password, + Name: user.Name, + Email: user.Email, + AuthToken: user.AuthToken, + Authorized: user.Authorized, + Admin: user.Admin, + Organization: user.Organization, + }, nil +} diff --git a/lib/error/error.go b/lib/error/error.go new file mode 100644 index 0000000..865f56c --- /dev/null +++ b/lib/error/error.go @@ -0,0 +1,16 @@ +package error + +import "fmt" + +type WrappedError struct { + Err error + Context string +} + +func (e *WrappedError) Error() string { + return fmt.Sprintf("%s: %s", e.Context, e.Err.Error()) +} + +func Wrap(err error, context string) *WrappedError { + return &WrappedError{Err: err, Context: context} +} diff --git a/lib/locator/locator.go b/lib/locator/locator.go new file mode 100644 index 0000000..f3178b9 --- /dev/null +++ b/lib/locator/locator.go @@ -0,0 +1,33 @@ +package locator + +import ( + perr "git.markbailey.dev/cervers/ptpp/lib/error" + "git.markbailey.dev/cervers/ptpp/lib/locator/service" + "sync" +) + +type Locator struct { + services map[string]service.Service + mu sync.RWMutex +} + +func (l *Locator) Register(s service.Service) { + l.mu.Lock() + defer l.mu.Unlock() + + if l.services == nil { + l.services = make(map[string]service.Service) + } + l.services[s.Name()] = s +} + +func (l *Locator) Locate(name string) (service.Service, error) { + l.mu.RLock() + defer l.mu.RUnlock() + + s, ok := l.services[name] + if !ok { + return nil, perr.Wrap(service.ErrServiceNotFound{Name: name}, "locator") + } + return s, nil +} diff --git a/lib/locator/service/database.go b/lib/locator/service/database.go new file mode 100644 index 0000000..0d4bdee --- /dev/null +++ b/lib/locator/service/database.go @@ -0,0 +1,32 @@ +package service + +import ( + "git.markbailey.dev/cervers/ptpp/lib/database" + "sync" +) + +var ( + db interface{} + dlock = &sync.Mutex{} +) + +type SDB struct{} + +func NewSDB() *SDB { + return &SDB{} +} + +func (s *SDB) Name() string { + return "db" +} + +func (s *SDB) Use() interface{} { + if db == nil { + dlock.Lock() + defer dlock.Unlock() + + db = database.ChooseDB() + } + + return db +} diff --git a/lib/locator/service/logger.go b/lib/locator/service/logger.go new file mode 100644 index 0000000..61ec7d0 --- /dev/null +++ b/lib/locator/service/logger.go @@ -0,0 +1,32 @@ +package service + +import ( + "git.markbailey.dev/cervers/ptpp/lib/logger" + "sync" +) + +var ( + log interface{} + llock = &sync.Mutex{} +) + +type SLogger struct{} + +func NewSLogger() *SLogger { + return &SLogger{} +} + +func (s *SLogger) Name() string { + return "logger" +} + +func (s *SLogger) Use() interface{} { + if log == nil { + llock.Lock() + defer llock.Unlock() + + log = logger.NewCompositeLogger() + } + + return log +} diff --git a/lib/locator/service/service.go b/lib/locator/service/service.go new file mode 100644 index 0000000..38c7ef7 --- /dev/null +++ b/lib/locator/service/service.go @@ -0,0 +1,16 @@ +package service + +import "fmt" + +type Service interface { + Name() string + Use() interface{} +} + +type ErrServiceNotFound struct { + Name string +} + +func (e ErrServiceNotFound) Error() string { + return fmt.Sprintf("service %s not found", e.Name) +} diff --git a/lib/logger/composite.go b/lib/logger/composite.go new file mode 100644 index 0000000..5c07c80 --- /dev/null +++ b/lib/logger/composite.go @@ -0,0 +1,80 @@ +package logger + +import ( + "fmt" + pterror "git.markbailey.dev/cervers/ptpp/lib/error" + "git.markbailey.dev/cervers/ptpp/util" + "log" + "os" + "sync" +) + +var ( + concreteCompositeLog *Log + compositeLogFile *os.File + compositeLoggerLock = &sync.Mutex{} + compositeLogFileLock = &sync.Mutex{} +) + +type CompositeLogger struct{} + +func NewCompositeLogger() *CompositeLogger { + return &CompositeLogger{} +} + +func (l CompositeLogger) GetLogger() *Log { + if concreteCompositeLog == nil { + compositeLoggerLock.Lock() + defer compositeLoggerLock.Unlock() + + logFile := l.getLogFile() + + concreteCompositeLog = &Log{ + Error: log.New(logFile, "Error:\t", log.Ldate|log.Ltime), + Warn: log.New(logFile, "Warn:\t", log.Ldate|log.Ltime), + Info: log.New(logFile, "Info:\t", log.Ldate|log.Ltime), + } + } + + return concreteCompositeLog +} + +func (l CompositeLogger) getLogFile() *os.File { + if compositeLogFile == nil { + compositeLogFileLock.Lock() + defer compositeLogFileLock.Unlock() + + absPath := util.GetFullyQualifiedPath("/log") + + err := os.MkdirAll(absPath, os.ModePerm) + if err != nil { + fmt.Println("Error creating directory:", err) + os.Exit(1) + } + + generalLog, err := os.OpenFile(absPath+"/general-log.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + fmt.Println("Error opening file:", err) + os.Exit(1) + } + + compositeLogFile = generalLog + } + + return compositeLogFile +} +func (l CompositeLogger) Error(e error) { + l.GetLogger().Error.Println(e) +} + +func (l CompositeLogger) Warn(w string) { + l.GetLogger().Warn.Println(w) +} + +func (l CompositeLogger) Info(i string) { + l.GetLogger().Info.Println(i) +} + +func (l CompositeLogger) Wrap(e error, context string) error { + return pterror.Wrap(e, context) +} diff --git a/lib/logger/database.go b/lib/logger/database.go new file mode 100644 index 0000000..9347df9 --- /dev/null +++ b/lib/logger/database.go @@ -0,0 +1,75 @@ +package logger + +import ( + "fmt" + pterror "git.markbailey.dev/cervers/ptpp/lib/error" + "git.markbailey.dev/cervers/ptpp/util" + "log" + "os" + "sync" +) + +var ( + dbLog *Log + dbLogFile *os.File + dbLoggerLock = &sync.Mutex{} + dbLogFileLock = &sync.Mutex{} +) + +type DBLogger struct{} + +func NewDBLogger() *DBLogger { + return &DBLogger{} +} + +func (l DBLogger) GetLogger() *Log { + if dbLog == nil { + dbLoggerLock.Lock() + defer dbLoggerLock.Unlock() + + logFile := l.getLogFile() + + dbLog = &Log{ + Error: log.New(logFile, "ERROR:\t", log.Ldate|log.Ltime), + Warn: log.New(logFile, "WARN:\t", log.Ldate|log.Ltime), + Info: log.New(logFile, "INFO:\t", log.Ldate|log.Ltime), + } + } + + return dbLog +} + +func (l DBLogger) getLogFile() *os.File { + if dbLogFile == nil { + dbLogFileLock.Lock() + defer dbLogFileLock.Unlock() + + absPath := util.GetFullyQualifiedPath("/log") + + generalLog, err := os.OpenFile(absPath+"/db-log.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + fmt.Println("Error opening file:", err) + os.Exit(1) + } + + dbLogFile = generalLog + } + + return dbLogFile +} + +func (l DBLogger) Error(e error) { + l.GetLogger().Error.Println(e) +} + +func (l DBLogger) Warn(w string) { + l.GetLogger().Warn.Println(w) +} + +func (l DBLogger) Info(i string) { + l.GetLogger().Info.Println(i) +} + +func (l DBLogger) Wrap(e error, context string) error { + return pterror.Wrap(e, context) +} diff --git a/lib/logger/logger.go b/lib/logger/logger.go new file mode 100644 index 0000000..a4b0a28 --- /dev/null +++ b/lib/logger/logger.go @@ -0,0 +1,21 @@ +package logger + +import ( + "log" + "os" +) + +type ILogger interface { + GetLogger() *Log + Error(error) + Warn(string) + Info(string) + Wrap(error, string) error + getLogFile() *os.File +} + +type Log struct { + Error *log.Logger + Warn *log.Logger + Info *log.Logger +} diff --git a/lib/middleware/middleware.go b/lib/middleware/middleware.go new file mode 100644 index 0000000..c3ab1e1 --- /dev/null +++ b/lib/middleware/middleware.go @@ -0,0 +1,159 @@ +package middleware + +import ( + "fmt" + "git.markbailey.dev/cervers/ptpp/lib/database" + "net/http" + "os" + + "git.markbailey.dev/cervers/ptpp/lib/logger" + "git.markbailey.dev/cervers/ptpp/lib/session" + "git.markbailey.dev/cervers/ptpp/util" +) + +type Middleware struct { + l logger.ILogger + db database.IDB +} + +type ( + ErrHandler func(http.ResponseWriter, *http.Request) error + Func func(http.Handler) http.Handler +) + +var ( + sess session.IManager +) + +func (fn ErrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := fn(w, r); err != nil { + w.Header().Set("HX-Retarget", "#layout_content") + w.Header().Set("HX-Reswap", "innerHTML") + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func Compose(xs ...Func) Func { + return func(next http.Handler) http.Handler { + for i := len(xs) - 1; i >= 0; i-- { + x := xs[i] + next = x(next) + } + + return next + } +} + +func WithLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerSess := sess.SessionStart(w, r) + + username, ok := handlerSess.Get("username").(string) + if !ok { + username = "" + } + + ipAddr := r.Header.Get("X-Real-IP") + if ipAddr == "" { + ipAddr = r.Header.Get("X-Forwarded-For") + } + if ipAddr == "" { + ipAddr = r.RemoteAddr + } + + handlerLogger := logger.NewCompositeLogger() + output := fmt.Sprintf( + "%s Request sent from %s to %s (username? %s)", + r.Method, + ipAddr, + r.URL.Path, + username, + ) + handlerLogger.Info(output) + + next.ServeHTTP(w, r) + }) +} + +func WithAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + claims *util.CustomClaims + cookie *http.Cookie + err error + token string + handlerSess = sess.SessionStart(w, r) + ) + + if handlerSess.Get("username") != nil { + req := util.AddValuesToRequestContext(r, map[any]any{ + "username": handlerSess.Get("username"), + }) + next.ServeHTTP(w, req) + return + } + + if cookie, err = r.Cookie("token"); err != nil { + _ = util.Redirect(w, r, "/signin", http.StatusSeeOther, true) + return + } + if token = cookie.Value; token == "" { + _ = util.Redirect(w, r, "/signin", http.StatusSeeOther, true) + return + } + if claims, err = util.ParseToken(token, os.Getenv("TOKEN_SECRET")); err != nil { + _ = util.Redirect(w, r, "/signin", http.StatusSeeOther, true) + return + } + + req := util.AddValuesToRequestContext(r, map[any]any{ + "username": claims.Username, + }) + next.ServeHTTP(w, req) + }) +} + +func WithUsername(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + claims *util.CustomClaims + cookie *http.Cookie + err error + token string + handlerSess = sess.SessionStart(w, r) + ) + + if handlerSess.Get("username") != nil { + req := util.AddValuesToRequestContext(r, map[any]any{ + "username": handlerSess.Get("username"), + }) + next.ServeHTTP(w, req) + return + } + + var uname *string + if cookie, err = r.Cookie("token"); err == nil { + if token = cookie.Value; token == "" { + uname = nil + } + if claims, err = util.ParseToken(token, os.Getenv("TOKEN_SECRET")); err != nil { + uname = nil + } + uname = &claims.Username + } + + if uname != nil { + req := util.AddValuesToRequestContext(r, map[any]any{ + "username": uname, + }) + next.ServeHTTP(w, req) + return + } + + next.ServeHTTP(w, r) + }) +} + +func init() { + sess, _ = session.NewManager("memory", "ptpp", 3600) +} diff --git a/lib/repository/repository.go b/lib/repository/repository.go new file mode 100644 index 0000000..4a03aa4 --- /dev/null +++ b/lib/repository/repository.go @@ -0,0 +1,26 @@ +package repository + +import "git.markbailey.dev/cervers/ptpp/lib/database/dto" + +type IHeartbeat interface { + CreateHeartbeat(h *dto.Heartbeat) (*dto.Heartbeat, error) + FindHeartbeatByID(id int64) (*dto.Heartbeat, error) + FindHeartbeatByUser(u dto.User) ([]*dto.Heartbeat, error) +} + +type IUser interface { + CreateUser(u dto.User) (*dto.User, error) + FindUserByUsername(username string) (*dto.User, error) +} + +type IOrganization interface { + CreateOrganization(o dto.Organization) (*dto.Organization, error) + FindOrganizationByName(name string) (*dto.Organization, error) + FindOrganizationById(id int64) (*dto.Organization, error) +} + +type IRepository interface { + IHeartbeat + IUser + IOrganization +} diff --git a/lib/session/memory/cookieprovider.go b/lib/session/memory/cookieprovider.go new file mode 100644 index 0000000..05af74b --- /dev/null +++ b/lib/session/memory/cookieprovider.go @@ -0,0 +1 @@ +package memory diff --git a/lib/session/memory/memprovider.go b/lib/session/memory/memprovider.go new file mode 100644 index 0000000..266db8e --- /dev/null +++ b/lib/session/memory/memprovider.go @@ -0,0 +1,123 @@ +package memory + +import ( + "container/list" + "sync" + "time" + + "git.markbailey.dev/cervers/ptpp/lib/session" +) + +var prov = &Provider{list: list.New()} + +type MemSessionStore struct { + timeAccessed time.Time + value map[interface{}]interface{} + sid string +} + +func (m *MemSessionStore) Set(key interface{}, value interface{}) error { + m.value[key] = value + err := prov.SessionUpdate(m.sid) + if err != nil { + return err + } + + return nil +} + +func (m *MemSessionStore) Get(key interface{}) interface{} { + err := prov.SessionUpdate(m.sid) + if err != nil { + return nil + } + if v, ok := m.value[key]; ok { + return v + } + + return nil +} + +func (m *MemSessionStore) Delete(key interface{}) error { + delete(m.value, key) + err := prov.SessionUpdate(m.sid) + if err != nil { + return err + } + + return nil +} + +func (m *MemSessionStore) SessionID() string { + return m.sid +} + +type Provider struct { + sessions map[string]*list.Element + list *list.List + lock sync.Mutex +} + +func (p *Provider) SessionInit(sid string) (session.ISession, error) { + prov.lock.Lock() + defer prov.lock.Unlock() + v := make(map[interface{}]interface{}, 0) + newSess := &MemSessionStore{sid: sid, timeAccessed: time.Now(), value: v} + elem := prov.list.PushBack(newSess) + prov.sessions[sid] = elem + return newSess, nil +} + +func (p *Provider) SessionRead(sid string) (session.ISession, error) { + if element, ok := prov.sessions[sid]; ok { + return element.Value.(*MemSessionStore), nil + } + + sess, err := prov.SessionInit(sid) + return sess, err +} + +func (p *Provider) SessionDestroy(sid string) error { + if element, ok := prov.sessions[sid]; ok { + delete(prov.sessions, sid) + prov.list.Remove(element) + } + + return nil +} + +func (p *Provider) SessionGC(maxLifeTime int64) { + prov.lock.Lock() + defer prov.lock.Unlock() + + for { + element := prov.list.Back() + if element == nil { + break + } + + if (element.Value.(*MemSessionStore).timeAccessed.Unix() + maxLifeTime) < time.Now().Unix() { + prov.list.Remove(element) + delete(prov.sessions, element.Value.(*MemSessionStore).sid) + } else { + break + } + } +} + +func (p *Provider) SessionUpdate(sid string) error { + p.lock.Lock() + defer p.lock.Unlock() + if elem, ok := p.sessions[sid]; ok { + elem.Value.(*MemSessionStore).timeAccessed = time.Now() + p.list.MoveToFront(elem) + return nil + } + + return nil +} + +func init() { + prov.sessions = make(map[string]*list.Element, 0) + session.Register("memory", prov) +} diff --git a/lib/session/session.go b/lib/session/session.go new file mode 100644 index 0000000..b13c4ce --- /dev/null +++ b/lib/session/session.go @@ -0,0 +1,105 @@ +package session + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +var ( + providers = make(map[string]IProvider) + session *Manager + lock sync.Mutex +) + +type Manager struct { + provider IProvider + cookieName string + maxLifetime int64 + lock sync.Mutex +} + +type IManager interface { + SessionStart(w http.ResponseWriter, r *http.Request) ISession + GC() +} + +type IProvider interface { + SessionInit(sid string) (ISession, error) + SessionRead(sid string) (ISession, error) + SessionDestroy(sid string) error + SessionGC(maxLifeTime int64) +} + +type ISession interface { + Set(key, value interface{}) error + Get(key interface{}) interface{} + Delete(key interface{}) error + SessionID() string +} + +func NewManager(providerName, cookieName string, maxLifetime int64) (*Manager, error) { + provider, ok := providers[providerName] + + if !ok { + return nil, fmt.Errorf("session: unknown provider %q", providerName) + } + + if session == nil { + lock.Lock() + defer lock.Unlock() + session = &Manager{provider: provider, cookieName: cookieName, maxLifetime: maxLifetime} + } + + return session, nil +} + +func (m *Manager) SessionStart(w http.ResponseWriter, r *http.Request) ISession { + m.lock.Lock() + defer m.lock.Unlock() + cookie, err := r.Cookie(m.cookieName) + var session ISession + if err != nil || cookie.Value == "" { + sid := m.sessionId() + session, _ = m.provider.SessionInit(sid) + cookie := http.Cookie{Name: m.cookieName, Value: sid, Path: "/", HttpOnly: true, MaxAge: int(m.maxLifetime)} + http.SetCookie(w, &cookie) + } else { + sid := cookie.Value + session, _ = m.provider.SessionRead(sid) + } + + return session +} + +func (m *Manager) GC() { + m.lock.Lock() + defer m.lock.Unlock() + m.provider.SessionGC(m.maxLifetime) + time.AfterFunc(time.Duration(m.maxLifetime), func() { m.GC() }) +} + +func (m *Manager) sessionId() string { + b := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "" + } + + return base64.URLEncoding.EncodeToString(b) +} + +func Register(name string, provider IProvider) { + if providers[name] != nil { + return + } + + if provider == nil { + panic("session: Register provider is nil") + } + + providers[name] = provider +} diff --git a/migrations/postgres/110920241037_init.sql b/migrations/postgres/110920241037_init.sql new file mode 100644 index 0000000..3d485d8 --- /dev/null +++ b/migrations/postgres/110920241037_init.sql @@ -0,0 +1,79 @@ +-- +goose Up +-- +goose StatementBegin +DROP TABLE IF EXISTS "heartbeat"; +DROP SEQUENCE IF EXISTS heartbeat_id_seq; +CREATE SEQUENCE heartbeat_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."heartbeat" +( + "id" integer DEFAULT nextval('heartbeat_id_seq') NOT NULL, + "user" integer NOT NULL, + "created_at" timestamptz NOT NULL, + "ip_addr" text NOT NULL, + "auth_token" text NOT NULL, + CONSTRAINT "heartbeat_pkey" PRIMARY KEY ("id") +) WITH (oids = false); + +CREATE INDEX "heartbeats_user_idx" ON "public"."heartbeat" USING btree ("user"); + + +DROP TABLE IF EXISTS "organization"; +DROP SEQUENCE IF EXISTS organization_id_seq; +CREATE SEQUENCE organization_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."organization" +( + "id" integer DEFAULT nextval('organization_id_seq') NOT NULL, + "name" text NOT NULL, + "owner_name" text NOT NULL, + "owner_phone" text NOT NULL, + "owner_email" text NOT NULL, + "created_at" timestamptz NOT NULL, + "deleted_at" timestamptz NULL DEFAULT NULL, + "authorized" integer NOT NULL, + "auth_token" text NOT NULL, + CONSTRAINT "organization_name_key" UNIQUE ("name"), + CONSTRAINT "organization_pkey" PRIMARY KEY ("id") +) WITH (oids = false); + +CREATE INDEX "organizations_name_idx" ON "public"."organization" USING btree ("name"); + +CREATE INDEX "organizations_owner_email_idx" ON "public"."organization" USING btree ("owner_email"); + +CREATE INDEX "organizations_owner_phone_idx" ON "public"."organization" USING btree ("owner_phone"); + +DROP TABLE IF EXISTS "user"; +DROP SEQUENCE IF EXISTS user_id_seq; +CREATE SEQUENCE user_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1; + +CREATE TABLE "public"."user" +( + "id" integer DEFAULT nextval('user_id_seq') NOT NULL, + "username" text NOT NULL, + "password" text NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "auth_token" text NOT NULL, + "authorized" integer NOT NULL, + "admin" integer NOT NULL, + "organization" integer NOT NULL, + CONSTRAINT "user_email_key" UNIQUE ("email"), + CONSTRAINT "user_pkey" PRIMARY KEY ("id"), + CONSTRAINT "user_username_key" UNIQUE ("username") +) WITH (oids = false); + +CREATE INDEX "user_email_idx" ON "public"."user" USING btree ("email"); + +CREATE INDEX "user_username_idx" ON "public"."user" USING btree ("username"); + + +ALTER TABLE ONLY "public"."heartbeat" + ADD CONSTRAINT "heartbeat_user_fkey" FOREIGN KEY ("user") REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE; + +ALTER TABLE ONLY "public"."user" + ADD CONSTRAINT "user_organization_fkey" FOREIGN KEY (organization) REFERENCES organization (id) ON DELETE CASCADE NOT DEFERRABLE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- +goose StatementEnd diff --git a/migrations/sqlite/110920241037_init.sql b/migrations/sqlite/110920241037_init.sql new file mode 100644 index 0000000..ed18a72 --- /dev/null +++ b/migrations/sqlite/110920241037_init.sql @@ -0,0 +1,52 @@ +-- +goose Up +-- +goose StatementBegin +PRAGMA foreign_keys = ON; + +CREATE TABLE "user" +( + id INTEGER PRIMARY KEY NOT NULL, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + auth_token TEXT NOT NULL, + authorized INTEGER NOT NULL, + admin INTEGER NOT NULL, + organization INTEGER NOT NULL, + FOREIGN KEY (organization) REFERENCES organization (id) ON UPDATE NO ACTION ON DELETE CASCADE +); + +CREATE TABLE heartbeat +( + id INTEGER PRIMARY KEY NOT NULL, + "user" INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ip_addr TEXT NOT NULL, + auth_token TEXT NOT NULL, + FOREIGN KEY ("user") REFERENCES "user" (id) ON UPDATE NO ACTION ON DELETE CASCADE +); + +CREATE TABLE organization +( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE, + owner_name TEXT NOT NULL, + owner_phone TEXT NOT NULL, + owner_email TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME, + authorized INTEGER NOT NULL, + auth_token TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS user_username ON "user" (username); +CREATE INDEX IF NOT EXISTS user_email ON "user" (email); +CREATE INDEX IF NOT EXISTS heartbeats_user ON heartbeat ("user"); +CREATE INDEX IF NOT EXISTS organizations_name ON organization (name); +CREATE INDEX IF NOT EXISTS organizations_owner_phone ON organization (owner_phone); +CREATE INDEX IF NOT EXISTS organizations_owner_email ON organization (owner_email); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- +goose StatementEnd diff --git a/models/postgres/db.go b/models/postgres/db.go new file mode 100644 index 0000000..825da14 --- /dev/null +++ b/models/postgres/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package postgres + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/models/postgres/heartbeat.sql.go b/models/postgres/heartbeat.sql.go new file mode 100644 index 0000000..d5336ee --- /dev/null +++ b/models/postgres/heartbeat.sql.go @@ -0,0 +1,98 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: heartbeat.sql + +package postgres + +import ( + "context" + "time" +) + +const createHeartbeat = `-- name: CreateHeartbeat :one +INSERT INTO "heartbeat" + ("user", created_at, ip_addr, auth_token) +VALUES ($1, $2, $3, $4) +RETURNING id, "user", created_at, ip_addr, auth_token +` + +type CreateHeartbeatParams struct { + User int64 `db:"user" json:"user"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + IpAddr string `db:"ip_addr" json:"ip_addr"` + AuthToken string `db:"auth_token" json:"auth_token"` +} + +func (q *Queries) CreateHeartbeat(ctx context.Context, arg CreateHeartbeatParams) (Heartbeat, error) { + row := q.db.QueryRowContext(ctx, createHeartbeat, + arg.User, + arg.CreatedAt, + arg.IpAddr, + arg.AuthToken, + ) + var i Heartbeat + err := row.Scan( + &i.Identifier, + &i.User, + &i.CreatedAt, + &i.IpAddr, + &i.AuthToken, + ) + return i, err +} + +const findHeartbeatByID = `-- name: FindHeartbeatByID :one +SELECT id, "user", created_at, ip_addr, auth_token +FROM "heartbeat" +WHERE id = $1 +` + +func (q *Queries) FindHeartbeatByID(ctx context.Context, id int64) (Heartbeat, error) { + row := q.db.QueryRowContext(ctx, findHeartbeatByID, id) + var i Heartbeat + err := row.Scan( + &i.Identifier, + &i.User, + &i.CreatedAt, + &i.IpAddr, + &i.AuthToken, + ) + return i, err +} + +const findHeartbeatByUser = `-- name: FindHeartbeatByUser :many +SELECT id, "user", created_at, ip_addr, auth_token +FROM "heartbeat" +WHERE "user" = $1 +ORDER BY created_at DESC +` + +func (q *Queries) FindHeartbeatByUser(ctx context.Context, user int64) ([]Heartbeat, error) { + rows, err := q.db.QueryContext(ctx, findHeartbeatByUser, user) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Heartbeat{} + for rows.Next() { + var i Heartbeat + if err := rows.Scan( + &i.Identifier, + &i.User, + &i.CreatedAt, + &i.IpAddr, + &i.AuthToken, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/models/postgres/models.go b/models/postgres/models.go new file mode 100644 index 0000000..353dead --- /dev/null +++ b/models/postgres/models.go @@ -0,0 +1,41 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package postgres + +import ( + "time" +) + +type Heartbeat struct { + Identifier int64 `db:"id" json:"id"` + User int64 `db:"user" json:"user"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + IpAddr string `db:"ip_addr" json:"ip_addr"` + AuthToken string `db:"auth_token" json:"auth_token"` +} + +type Organization struct { + Identifier int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + OwnerName string `db:"owner_name" json:"owner_name"` + OwnerPhone string `db:"owner_phone" json:"owner_phone"` + OwnerEmail string `db:"owner_email" json:"owner_email"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + DeletedAt *time.Time `db:"deleted_at" json:"deleted_at"` + Authorized int64 `db:"authorized" json:"authorized"` + AuthToken string `db:"auth_token" json:"auth_token"` +} + +type User struct { + Identifier int64 `db:"id" json:"id"` + Username string `db:"username" json:"username"` + Password string `db:"password" json:"password"` + Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` + AuthToken string `db:"auth_token" json:"auth_token"` + Authorized int64 `db:"authorized" json:"authorized"` + Admin int64 `db:"admin" json:"admin"` + Organization int64 `db:"organization" json:"organization"` +} diff --git a/models/postgres/organization.sql.go b/models/postgres/organization.sql.go new file mode 100644 index 0000000..c17d067 --- /dev/null +++ b/models/postgres/organization.sql.go @@ -0,0 +1,99 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: organization.sql + +package postgres + +import ( + "context" + "time" +) + +const createOrganization = `-- name: CreateOrganization :one +INSERT INTO "organization" + (name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token +` + +type CreateOrganizationParams struct { + Name string `db:"name" json:"name"` + OwnerName string `db:"owner_name" json:"owner_name"` + OwnerPhone string `db:"owner_phone" json:"owner_phone"` + OwnerEmail string `db:"owner_email" json:"owner_email"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + DeletedAt *time.Time `db:"deleted_at" json:"deleted_at"` + Authorized int64 `db:"authorized" json:"authorized"` + AuthToken string `db:"auth_token" json:"auth_token"` +} + +func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, error) { + row := q.db.QueryRowContext(ctx, createOrganization, + arg.Name, + arg.OwnerName, + arg.OwnerPhone, + arg.OwnerEmail, + arg.CreatedAt, + arg.DeletedAt, + arg.Authorized, + arg.AuthToken, + ) + var i Organization + err := row.Scan( + &i.Identifier, + &i.Name, + &i.OwnerName, + &i.OwnerPhone, + &i.OwnerEmail, + &i.CreatedAt, + &i.DeletedAt, + &i.Authorized, + &i.AuthToken, + ) + return i, err +} + +const findOrganizationById = `-- name: FindOrganizationById :one +SELECT id, name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token FROM "organization" +WHERE id = $1 +` + +func (q *Queries) FindOrganizationById(ctx context.Context, id int64) (Organization, error) { + row := q.db.QueryRowContext(ctx, findOrganizationById, id) + var i Organization + err := row.Scan( + &i.Identifier, + &i.Name, + &i.OwnerName, + &i.OwnerPhone, + &i.OwnerEmail, + &i.CreatedAt, + &i.DeletedAt, + &i.Authorized, + &i.AuthToken, + ) + return i, err +} + +const findOrganizationByName = `-- name: FindOrganizationByName :one +SELECT id, name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token FROM "organization" +WHERE name = $1 +` + +func (q *Queries) FindOrganizationByName(ctx context.Context, name string) (Organization, error) { + row := q.db.QueryRowContext(ctx, findOrganizationByName, name) + var i Organization + err := row.Scan( + &i.Identifier, + &i.Name, + &i.OwnerName, + &i.OwnerPhone, + &i.OwnerEmail, + &i.CreatedAt, + &i.DeletedAt, + &i.Authorized, + &i.AuthToken, + ) + return i, err +} diff --git a/models/postgres/user.sql.go b/models/postgres/user.sql.go new file mode 100644 index 0000000..fccbe62 --- /dev/null +++ b/models/postgres/user.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: user.sql + +package postgres + +import ( + "context" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO "user" + (username, password, name, email, auth_token, authorized, admin, organization) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, username, password, name, email, auth_token, authorized, admin, organization +` + +type CreateUserParams struct { + Username string `db:"username" json:"username"` + Password string `db:"password" json:"password"` + Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` + AuthToken string `db:"auth_token" json:"auth_token"` + Authorized int64 `db:"authorized" json:"authorized"` + Admin int64 `db:"admin" json:"admin"` + Organization int64 `db:"organization" json:"organization"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, + arg.Username, + arg.Password, + arg.Name, + arg.Email, + arg.AuthToken, + arg.Authorized, + arg.Admin, + arg.Organization, + ) + var i User + err := row.Scan( + &i.Identifier, + &i.Username, + &i.Password, + &i.Name, + &i.Email, + &i.AuthToken, + &i.Authorized, + &i.Admin, + &i.Organization, + ) + return i, err +} + +const findUserByUsername = `-- name: FindUserByUsername :one +SELECT id, username, password, name, email, auth_token, authorized, admin, organization FROM "user" +WHERE username = $1 +` + +func (q *Queries) FindUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRowContext(ctx, findUserByUsername, username) + var i User + err := row.Scan( + &i.Identifier, + &i.Username, + &i.Password, + &i.Name, + &i.Email, + &i.AuthToken, + &i.Authorized, + &i.Admin, + &i.Organization, + ) + return i, err +} diff --git a/models/sqlite/db.go b/models/sqlite/db.go new file mode 100644 index 0000000..daca62a --- /dev/null +++ b/models/sqlite/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sqlite + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/models/sqlite/heartbeat.sql.go b/models/sqlite/heartbeat.sql.go new file mode 100644 index 0000000..d5ab752 --- /dev/null +++ b/models/sqlite/heartbeat.sql.go @@ -0,0 +1,98 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: heartbeat.sql + +package sqlite + +import ( + "context" + "time" +) + +const createHeartbeat = `-- name: CreateHeartbeat :one +INSERT INTO heartbeat + ("user", created_at, ip_addr, auth_token) +VALUES (?, ?, ?, ?) +RETURNING id, user, created_at, ip_addr, auth_token +` + +type CreateHeartbeatParams struct { + User int64 `db:"user" json:"user"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + IpAddr string `db:"ip_addr" json:"ip_addr"` + AuthToken string `db:"auth_token" json:"auth_token"` +} + +func (q *Queries) CreateHeartbeat(ctx context.Context, arg CreateHeartbeatParams) (Heartbeat, error) { + row := q.db.QueryRowContext(ctx, createHeartbeat, + arg.User, + arg.CreatedAt, + arg.IpAddr, + arg.AuthToken, + ) + var i Heartbeat + err := row.Scan( + &i.Identifier, + &i.User, + &i.CreatedAt, + &i.IpAddr, + &i.AuthToken, + ) + return i, err +} + +const findHeartbeatByID = `-- name: FindHeartbeatByID :one +SELECT id, user, created_at, ip_addr, auth_token +FROM heartbeat +WHERE id = ? +` + +func (q *Queries) FindHeartbeatByID(ctx context.Context, id int64) (Heartbeat, error) { + row := q.db.QueryRowContext(ctx, findHeartbeatByID, id) + var i Heartbeat + err := row.Scan( + &i.Identifier, + &i.User, + &i.CreatedAt, + &i.IpAddr, + &i.AuthToken, + ) + return i, err +} + +const findHeartbeatByUser = `-- name: FindHeartbeatByUser :many +SELECT id, user, created_at, ip_addr, auth_token +FROM heartbeat +WHERE "user" = ? +ORDER BY created_at DESC +` + +func (q *Queries) FindHeartbeatByUser(ctx context.Context, user int64) ([]Heartbeat, error) { + rows, err := q.db.QueryContext(ctx, findHeartbeatByUser, user) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Heartbeat{} + for rows.Next() { + var i Heartbeat + if err := rows.Scan( + &i.Identifier, + &i.User, + &i.CreatedAt, + &i.IpAddr, + &i.AuthToken, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/models/sqlite/models.go b/models/sqlite/models.go new file mode 100644 index 0000000..e6baf5c --- /dev/null +++ b/models/sqlite/models.go @@ -0,0 +1,41 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sqlite + +import ( + "time" +) + +type Heartbeat struct { + Identifier int64 `db:"id" json:"id"` + User int64 `db:"user" json:"user"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + IpAddr string `db:"ip_addr" json:"ip_addr"` + AuthToken string `db:"auth_token" json:"auth_token"` +} + +type Organization struct { + Identifier int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + OwnerName string `db:"owner_name" json:"owner_name"` + OwnerPhone string `db:"owner_phone" json:"owner_phone"` + OwnerEmail string `db:"owner_email" json:"owner_email"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + DeletedAt *time.Time `db:"deleted_at" json:"deleted_at"` + Authorized int64 `db:"authorized" json:"authorized"` + AuthToken string `db:"auth_token" json:"auth_token"` +} + +type User struct { + Identifier int64 `db:"id" json:"id"` + Username string `db:"username" json:"username"` + Password string `db:"password" json:"password"` + Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` + AuthToken string `db:"auth_token" json:"auth_token"` + Authorized int64 `db:"authorized" json:"authorized"` + Admin int64 `db:"admin" json:"admin"` + Organization int64 `db:"organization" json:"organization"` +} diff --git a/models/sqlite/organization.sql.go b/models/sqlite/organization.sql.go new file mode 100644 index 0000000..d70bc6b --- /dev/null +++ b/models/sqlite/organization.sql.go @@ -0,0 +1,99 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: organization.sql + +package sqlite + +import ( + "context" + "time" +) + +const createOrganization = `-- name: CreateOrganization :one +INSERT INTO organization + (name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token +` + +type CreateOrganizationParams struct { + Name string `db:"name" json:"name"` + OwnerName string `db:"owner_name" json:"owner_name"` + OwnerPhone string `db:"owner_phone" json:"owner_phone"` + OwnerEmail string `db:"owner_email" json:"owner_email"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + DeletedAt *time.Time `db:"deleted_at" json:"deleted_at"` + Authorized int64 `db:"authorized" json:"authorized"` + AuthToken string `db:"auth_token" json:"auth_token"` +} + +func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, error) { + row := q.db.QueryRowContext(ctx, createOrganization, + arg.Name, + arg.OwnerName, + arg.OwnerPhone, + arg.OwnerEmail, + arg.CreatedAt, + arg.DeletedAt, + arg.Authorized, + arg.AuthToken, + ) + var i Organization + err := row.Scan( + &i.Identifier, + &i.Name, + &i.OwnerName, + &i.OwnerPhone, + &i.OwnerEmail, + &i.CreatedAt, + &i.DeletedAt, + &i.Authorized, + &i.AuthToken, + ) + return i, err +} + +const findOrganizationById = `-- name: FindOrganizationById :one +SELECT id, name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token FROM organization +WHERE id = ? +` + +func (q *Queries) FindOrganizationById(ctx context.Context, id int64) (Organization, error) { + row := q.db.QueryRowContext(ctx, findOrganizationById, id) + var i Organization + err := row.Scan( + &i.Identifier, + &i.Name, + &i.OwnerName, + &i.OwnerPhone, + &i.OwnerEmail, + &i.CreatedAt, + &i.DeletedAt, + &i.Authorized, + &i.AuthToken, + ) + return i, err +} + +const findOrganizationByName = `-- name: FindOrganizationByName :one +SELECT id, name, owner_name, owner_phone, owner_email, created_at, deleted_at, authorized, auth_token FROM organization +WHERE name = ? +` + +func (q *Queries) FindOrganizationByName(ctx context.Context, name string) (Organization, error) { + row := q.db.QueryRowContext(ctx, findOrganizationByName, name) + var i Organization + err := row.Scan( + &i.Identifier, + &i.Name, + &i.OwnerName, + &i.OwnerPhone, + &i.OwnerEmail, + &i.CreatedAt, + &i.DeletedAt, + &i.Authorized, + &i.AuthToken, + ) + return i, err +} diff --git a/models/sqlite/user.sql.go b/models/sqlite/user.sql.go new file mode 100644 index 0000000..a5cffa8 --- /dev/null +++ b/models/sqlite/user.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: user.sql + +package sqlite + +import ( + "context" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO "user" + (username, password, name, email, auth_token, authorized, admin, organization) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, username, password, name, email, auth_token, authorized, admin, organization +` + +type CreateUserParams struct { + Username string `db:"username" json:"username"` + Password string `db:"password" json:"password"` + Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` + AuthToken string `db:"auth_token" json:"auth_token"` + Authorized int64 `db:"authorized" json:"authorized"` + Admin int64 `db:"admin" json:"admin"` + Organization int64 `db:"organization" json:"organization"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, + arg.Username, + arg.Password, + arg.Name, + arg.Email, + arg.AuthToken, + arg.Authorized, + arg.Admin, + arg.Organization, + ) + var i User + err := row.Scan( + &i.Identifier, + &i.Username, + &i.Password, + &i.Name, + &i.Email, + &i.AuthToken, + &i.Authorized, + &i.Admin, + &i.Organization, + ) + return i, err +} + +const findUserByUsername = `-- name: FindUserByUsername :one +SELECT id, username, password, name, email, auth_token, authorized, admin, organization FROM "user" +WHERE username = ? +` + +func (q *Queries) FindUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRowContext(ctx, findUserByUsername, username) + var i User + err := row.Scan( + &i.Identifier, + &i.Username, + &i.Password, + &i.Name, + &i.Email, + &i.AuthToken, + &i.Authorized, + &i.Admin, + &i.Organization, + ) + return i, err +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..50a6353 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1419 @@ +{ + "name": "ptpp", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "htmx.org": "^1.9.10", + "preline": "^2.0.3" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.9", + "tailwindcss": "^3.4.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", + "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz", + "integrity": "sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20" + } + }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", + "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmx.org": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.10.tgz", + "integrity": "sha512-UgchasltTCrTuU2DQLom3ohHrBvwr7OqpwyAVJ9VxtNBng4XKkVsqrv0Qr3srqvM9ZNI3f1MmvVQQqK7KW/bTA==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", + "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "dev": true, + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", + "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.15", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", + "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/preline": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/preline/-/preline-2.0.3.tgz", + "integrity": "sha512-V/sLmRIHd23UCdvJNRKKszntgUqA0381erVzRpQ48NjjMOgI7DyFW4mr+lg387V0oeBc5Dx9Jxa5voppVoH9GA==", + "dependencies": { + "@popperjs/core": "^2.11.2" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", + "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.19.1", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "dev": true, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..30b2ebc --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "devDependencies": { + "@tailwindcss/forms": "^0.5.9", + "tailwindcss": "^3.4.1" + }, + "dependencies": { + "htmx.org": "^1.9.10", + "preline": "^2.0.3" + } +} diff --git a/project-files/10162024-ptpp-stats.png b/project-files/10162024-ptpp-stats.png new file mode 100644 index 0000000..49b231d Binary files /dev/null and b/project-files/10162024-ptpp-stats.png differ diff --git a/project-files/11052024-ptpp-stats.png b/project-files/11052024-ptpp-stats.png new file mode 100644 index 0000000..3c5db68 Binary files /dev/null and b/project-files/11052024-ptpp-stats.png differ diff --git a/project-files/11092024-ptpp-stats.png b/project-files/11092024-ptpp-stats.png new file mode 100644 index 0000000..176352b Binary files /dev/null and b/project-files/11092024-ptpp-stats.png differ diff --git a/project-files/entity-diagram.jpg b/project-files/entity-diagram.jpg new file mode 100644 index 0000000..f33e324 Binary files /dev/null and b/project-files/entity-diagram.jpg differ diff --git a/project-files/entity-diagram.puml b/project-files/entity-diagram.puml new file mode 100644 index 0000000..dbfebb1 --- /dev/null +++ b/project-files/entity-diagram.puml @@ -0,0 +1,83 @@ +@startuml + +entity User { + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + auth_token TEXT NOT NULL, + authorized INTEGER NOT NULL, + admin INTEGER NOT NULL, + organization INTEGER NOT NULL, + FOREIGN KEY (organization) REFERENCES Organization(id) ON UPDATE NO ACTION ON DELETE CASCADE +} + +entity Heartbeat { + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + user INTEGER NOT NULL, + created TEXT NOT NULL, + ip_addr TEXT NOT NULL, + auth_token TEXT NOT NULL, + FOREIGN KEY (user) REFERENCES User(id) ON UPDATE NO ACTION ON DELETE CASCADE +} + +entity Organization { + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name TEXT NOT NULL UNIQUE, + owner_name TEXT NOT NULL, + owner_phone TEXT NOT NULL, + owner_email TEXT NOT NULL, + created TEXT NOT NULL, + deleted TEXT NULL DEFAULT NULL, + authorized INTEGER NOT NULL, + auth_token TEXT NOT NULL, +} + +entity Pattern { + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name TEXT NOT NULL UNIQUE, + description TEXT, + expected_delivery_time DATETIME DEFAULT CURRENT_TIMESTAMP, + time_to_complete INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, +} + +entity PatternSegment { + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + pattern_id INTEGER NOT NULL, + segment TEXT NOT NULL, + description TEXT, + punch_on DATETIME DEFAULT CURRENT_TIMESTAMP, + punch_off DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (pattern_id) REFERENCES Pattern(id) ON UPDATE NO ACTION ON DELETE CASCADE +} + +entity PatternSegmentDetail { + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + segment_id INTEGER NOT NULL, + details TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (segment_id) REFERENCES PatternSegment(id) ON UPDATE NO ACTION ON DELETE CASCADE +} + +entity PatternSegmentInstruction { + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + segment_id INTEGER NOT NULL, + instruction TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (segment_id) REFERENCES PatternSegment(id) ON UPDATE NO ACTION ON DELETE CASCADE +} + +Heartbeat::user --> User::id +User::organization --> Organization::id +PatternSegment::pattern_id --> Pattern::id +PatternSegmentDetail::segment_id --> PatternSegment::id +PatternSegmentInstruction::segment_id --> PatternSegment::id + +@enduml diff --git a/public/assets/css/index.css b/public/assets/css/index.css new file mode 100644 index 0000000..c4502eb --- /dev/null +++ b/public/assets/css/index.css @@ -0,0 +1,164 @@ +@import url('https://fonts.googleapis.com/css?family=Montserrat:400,600,700'); +@import url('https://fonts.googleapis.com/css?family=Catamaran:400,800'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +#NOT_FOUND { + .error-container { + text-align: center; + font-size: 106px; + font-family: 'Catamaran', sans-serif; + font-weight: 800; + margin: 70px 15px; + } + + .error-container > span { + display: inline-block; + position: relative; + } + + .error-container > span.four { + width: 136px; + height: 43px; + border-radius: 999px; + background: linear-gradient(140deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 43%, transparent 44%, transparent 100%), + linear-gradient(105deg, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.06) 41%, rgba(0, 0, 0, 0.07) 76%, transparent 77%, transparent 100%), + linear-gradient(to right, #d89ca4, #e27b7e); + } + + .error-container > span.four:before, + .error-container > span.four:after { + content: ''; + display: block; + position: absolute; + border-radius: 999px; + } + + .error-container > span.four:before { + width: 43px; + height: 156px; + left: 60px; + bottom: -43px; + background: linear-gradient(128deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 40%, transparent 41%, transparent 100%), + linear-gradient(116deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 50%, transparent 51%, transparent 100%), + linear-gradient(to top, #99749D, #B895AB, #CC9AA6, #D7969E, #E0787F); + } + + .error-container > span.four:after { + width: 137px; + height: 43px; + transform: rotate(-49.5deg); + left: -18px; + bottom: 36px; + background: linear-gradient(to right, #99749D, #B895AB, #CC9AA6, #D7969E, #E0787F); + } + + .error-container > span.zero { + vertical-align: text-top; + width: 156px; + height: 156px; + border-radius: 999px; + background: linear-gradient(-45deg, transparent 0%, rgba(0, 0, 0, 0.06) 50%, transparent 51%, transparent 100%), + linear-gradient(to top right, #99749D, #99749D, #B895AB, #CC9AA6, #D7969E, #ED8687, #ED8687); + overflow: hidden; + animation: bgshadow 5s infinite; + } + + .error-container > span.zero:before { + content: ''; + display: block; + position: absolute; + transform: rotate(45deg); + width: 90px; + height: 90px; + left: 0; + bottom: 0; + background: transparent linear-gradient(95deg, transparent 0%, transparent 8%, rgba(0, 0, 0, 0.07) 9%, transparent 50%, transparent 100%) linear-gradient(85deg, transparent 0%, transparent 19%, rgba(0, 0, 0, 0.05) 20%, rgba(0, 0, 0, 0.07) 91%, transparent 92%, transparent 100%); + } + + .error-container > span.zero:after { + content: ''; + display: block; + position: absolute; + border-radius: 999px; + width: 70px; + height: 70px; + left: 43px; + bottom: 43px; + background: #FDFAF5; + box-shadow: -2px 2px 2px 0 rgba(0, 0, 0, 0.1); + } + + .screen-reader-text { + position: absolute; + top: -9999em; + left: -9999em; + } + + @keyframes bgshadow { + 0% { + box-shadow: inset -160px 160px 0 5px rgba(0, 0, 0, 0.4); + } + 45% { + box-shadow: inset 0 0 0 0 rgba(0, 0, 0, 0.1); + } + 55% { + box-shadow: inset 0 0 0 0 rgba(0, 0, 0, 0.1); + } + 100% { + box-shadow: inset 160px -160px 0 5px rgba(0, 0, 0, 0.4); + } + } + + /* demo stuff */ + + * { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + } + + body { + background-color: #FDFAF5; + margin-bottom: 50px; + } + + html, button, input, select, textarea { + font-family: 'Montserrat', Helvetica, sans-serif; + color: #bbb; + } + + h1 { + text-align: center; + margin: 30px 15px; + } + + .zoom-area { + max-width: 490px; + margin: 30px auto 30px; + font-size: 19px; + text-align: center; + } + + .link-container { + text-align: center; + } + + a.more-link { + text-transform: uppercase; + font-size: 13px; + background-color: #de7e85; + padding: 10px 15px; + border-radius: 0; + color: #fff; + display: inline-block; + margin-right: 5px; + margin-bottom: 5px; + line-height: 1.5; + text-decoration: none; + margin-top: 50px; + letter-spacing: 1px; + } +} \ No newline at end of file diff --git a/public/assets/favicon/favicon.ico b/public/assets/favicon/favicon.ico new file mode 100644 index 0000000..be83be8 Binary files /dev/null and b/public/assets/favicon/favicon.ico differ diff --git a/public/assets/js/index.js b/public/assets/js/index.js new file mode 100644 index 0000000..37f1e3b --- /dev/null +++ b/public/assets/js/index.js @@ -0,0 +1,22 @@ +function ready(fn) { + if (document.readyState !== "loading") { + fn(); + return; + } + document.addEventListener("DOMContentLoaded", fn()); +} + +ready(() => { + document.querySelectorAll("form").forEach((e) => { + e.setAttribute("hx-ext", "json-enc"); + }); +}); + +ready(() => { + document + .getElementById("#layout_content") + .addEventListener("htmx:responseError", function (event) { + document.getElementById("#layout_content").innerHTML = + event.detail.xhr.response; + }); +}); diff --git a/sqlc.yml b/sqlc.yml new file mode 100644 index 0000000..744a565 --- /dev/null +++ b/sqlc.yml @@ -0,0 +1,41 @@ +version: "2" +sql: + - engine: "postgresql" + schema: "./database/schema/postgres.sql" + queries: "./database/query/postgres/" + gen: + go: + package: "postgres" + out: "models/postgres" + sql_package: "database/sql" + emit_db_tags: true + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true + - engine: "sqlite" + schema: "./database/schema/sqlite.sql" + queries: "./database/query/sqlite/" + gen: + go: + package: "sqlite" + out: "models/sqlite" + sql_package: "database/sql" + emit_db_tags: true + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true +overrides: + go: + rename: + id: "Identifier" + overrides: + - engine: "postgresql" + nullable: true + db_type: "timestamptz" + go_type: + type: "time.Time" + pointer: true + - engine: "postgresql" + unsigned: false + db_type: "pg_catalog.int4" + go_type: "int64" diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..e2471d7 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,7 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./view/**/*.{templ, go}"], + plugins: [ + require("@tailwindcss/forms") + ] +} \ No newline at end of file diff --git a/util/auth.go b/util/auth.go new file mode 100644 index 0000000..1610b5b --- /dev/null +++ b/util/auth.go @@ -0,0 +1,58 @@ +package util + +import ( + "errors" + "golang.org/x/crypto/bcrypt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type CustomClaims struct { + jwt.RegisteredClaims + Username string `json:"user_id"` +} + +func CreateTokenForUser(username string) (string, error) { + claims := CustomClaims{ + jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "ptpp", + }, + username, + } + str, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte(os.Getenv("TOKEN_SECRET"))) + if err != nil { + return "", err + } + + return str, nil +} + +func ParseToken(token string, secret string) (*CustomClaims, error) { + tk, err := jwt.ParseWithClaims(token, &CustomClaims{}, func(t *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + + if claims, ok := tk.Claims.(*CustomClaims); ok { + return claims, nil + } + + return nil, errors.New("invalid claims") +} + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 8) + return string(bytes), err +} + +func CheckPassword(password string, hash string) (bool, error) { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil, err +} diff --git a/util/shared.go b/util/shared.go new file mode 100644 index 0000000..0b28df1 --- /dev/null +++ b/util/shared.go @@ -0,0 +1,60 @@ +package util + +import ( + "context" + "log" + "net/http" + "os" + "path" + "path/filepath" +) + +func Redirect(w http.ResponseWriter, r *http.Request, path string, status int, forceRedirect bool) error { + w.Header().Set("HX-Redirect", path) + + if forceRedirect { + http.Redirect(w, r, path, status) + } + + return nil +} + +func AddValuesToRequestContext(r *http.Request, values map[any]any) *http.Request { + req := r + for k, v := range values { + req = req.WithContext(context.WithValue(req.Context(), k, v)) + } + + return req +} + +func GetBasePath() string { + dir, err := os.Executable() + if err != nil { + log.Fatal(err.Error()) + } + + basePath, err := filepath.Abs(path.Dir(dir)) + if err != nil { + log.Fatal(err.Error()) + } + + return basePath +} + +// TODO: refactor this. I went too hard +func GetFullyQualifiedPath(subject string) string { + fqp, err := filepath.Abs(GetBasePath() + prefixPath(subject)) + if err != nil { + log.Fatal(err.Error()) + } + + return fqp +} + +func prefixPath(s string) string { + if s[0:1] != "/" { + return s + "/" + } + return s +} diff --git a/view/admin/index.templ b/view/admin/index.templ new file mode 100644 index 0000000..38fdb62 --- /dev/null +++ b/view/admin/index.templ @@ -0,0 +1,10 @@ +package admin + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +templ Index(name string) { + @layout.Layout() { +

Welcome, { name }

+
You are an admin in a protected area of the site. Please do not share your password with anyone
+ } +} diff --git a/view/admin/index_templ.go b/view/admin/index_templ.go new file mode 100644 index 0000000..f3a84a5 --- /dev/null +++ b/view/admin/index_templ.go @@ -0,0 +1,73 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.793 +package admin + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +func Index(name string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Welcome, ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/admin/index.templ`, Line: 7, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

You are an admin in a protected area of the site. Please do not share your password with anyone
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = layout.Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/view/homepage/homepage.templ b/view/homepage/homepage.templ new file mode 100644 index 0000000..f194882 --- /dev/null +++ b/view/homepage/homepage.templ @@ -0,0 +1,27 @@ +package homepage + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +templ Homepage(env string) { + @layout.Layout() { +
+
+ Welcome to the homepage +
+ if env == "development" { +
+ + + + + + +
+ } +
+ } +} diff --git a/view/homepage/homepage_templ.go b/view/homepage/homepage_templ.go new file mode 100644 index 0000000..e211d31 --- /dev/null +++ b/view/homepage/homepage_templ.go @@ -0,0 +1,70 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.793 +package homepage + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +func Homepage(env string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Welcome to the homepage
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if env == "development" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = layout.Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/view/layout/layout.templ b/view/layout/layout.templ new file mode 100644 index 0000000..65bfe2d --- /dev/null +++ b/view/layout/layout.templ @@ -0,0 +1,25 @@ +package layout + +templ Layout() { + + + + + + + + + + + + + + +
+ { children... } +
+ + + + +} diff --git a/view/layout/layout_templ.go b/view/layout/layout_templ.go new file mode 100644 index 0000000..e6a4dad --- /dev/null +++ b/view/layout/layout_templ.go @@ -0,0 +1,48 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.793 +package layout + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Layout() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/view/layout/notfound.templ b/view/layout/notfound.templ new file mode 100644 index 0000000..89dae2a --- /dev/null +++ b/view/layout/notfound.templ @@ -0,0 +1,17 @@ +package layout + + +templ NotFound() { + @Layout() { +
+
+ 4 + 0 + 4 +
+ +
+ } +} \ No newline at end of file diff --git a/view/layout/notfound_templ.go b/view/layout/notfound_templ.go new file mode 100644 index 0000000..3155988 --- /dev/null +++ b/view/layout/notfound_templ.go @@ -0,0 +1,58 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.793 +package layout + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func NotFound() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
4 0 4
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/view/user/signin.templ b/view/user/signin.templ new file mode 100644 index 0000000..f140a7f --- /dev/null +++ b/view/user/signin.templ @@ -0,0 +1,69 @@ +package user + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +templ SignIn(err string) { + @layout.Layout() { +
+
+
+
+

+ Create an account +

+
+
+ + +
+
+ + +
+ if err != "" { +
+ { err } +
+ } + +
+
+
+
+
+ } +} diff --git a/view/user/signin_templ.go b/view/user/signin_templ.go new file mode 100644 index 0000000..ea6e9b5 --- /dev/null +++ b/view/user/signin_templ.go @@ -0,0 +1,83 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.793 +package user + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +func SignIn(err string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Create an account

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if err != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(err) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/user/signin.templ`, Line: 54, Col: 14} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = layout.Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/view/user/signup.templ b/view/user/signup.templ new file mode 100644 index 0000000..9462e6e --- /dev/null +++ b/view/user/signup.templ @@ -0,0 +1,66 @@ +package user + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +templ SignUpForm() { +@layout.Layout() { +
+
+
+
+

+ Create an account +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+} +} diff --git a/view/user/signup_templ.go b/view/user/signup_templ.go new file mode 100644 index 0000000..08be5c1 --- /dev/null +++ b/view/user/signup_templ.go @@ -0,0 +1,60 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.793 +package user + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +func SignUpForm() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Create an account

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = layout.Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/view/user/signupsuccess.templ b/view/user/signupsuccess.templ new file mode 100644 index 0000000..79b432e --- /dev/null +++ b/view/user/signupsuccess.templ @@ -0,0 +1,16 @@ +package user + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +templ SignUpSuccess() { + @layout.Layout() { +
+
+
+

Sign Up Success

+

Thank you for signing up. Please check your email to verify your account.

+
+
+
+ } +} diff --git a/view/user/signupsuccess_templ.go b/view/user/signupsuccess_templ.go new file mode 100644 index 0000000..c426192 --- /dev/null +++ b/view/user/signupsuccess_templ.go @@ -0,0 +1,60 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.793 +package user + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "git.markbailey.dev/cervers/ptpp/view/layout" + +func SignUpSuccess() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Sign Up Success

Thank you for signing up. Please check your email to verify your account.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = layout.Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate