From 0f4625a5a6532d9b66a6bbaaf9083f62834c4279 Mon Sep 17 00:00:00 2001 From: jasl Date: Wed, 27 Nov 2024 05:45:20 +0800 Subject: [PATCH] First commit --- .devcontainer/Dockerfile | 3 + .devcontainer/compose.yaml | 40 ++ .devcontainer/devcontainer.json | 35 ++ .dockerignore | 57 +++ .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 85 ++++ .gitignore | 44 ++ .kamal/hooks/docker-setup.sample | 3 + .kamal/hooks/post-deploy.sample | 14 + .kamal/hooks/post-proxy-reboot.sample | 3 + .kamal/hooks/pre-build.sample | 51 ++ .kamal/hooks/pre-connect.sample | 47 ++ .kamal/hooks/pre-deploy.sample | 109 +++++ .kamal/hooks/pre-proxy-reboot.sample | 3 + .kamal/secrets | 17 + .rubocop.yml | 28 ++ .ruby-version | 1 + Dockerfile | 70 +++ Gemfile | 60 +++ Gemfile.lock | 367 ++++++++++++++ README.md | 24 + Rakefile | 8 + app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 1 + app/controllers/api/application_controller.rb | 7 + app/controllers/api/events_controller.rb | 72 +++ app/controllers/api/home_controller.rb | 11 + .../api/recipients/application_controller.rb | 22 + .../api/recipients/events_controller.rb | 29 ++ .../api/topics/application_controller.rb | 22 + .../api/topics/events_controller.rb | 29 ++ app/controllers/application_controller.rb | 6 + app/controllers/concerns/.keep | 0 app/helpers/application_helper.rb | 4 + app/jobs/application_job.rb | 9 + app/lib/digest/keccak256.rb | 9 + app/lib/pagy/backends/cursor.rb | 46 ++ app/lib/pagy/backends/uuid_cursor.rb | 62 +++ app/lib/pagy/cursor.rb | 45 ++ app/models/application_record.rb | 5 + app/models/concerns/.keep | 0 app/models/concerns/nostr/nip1.rb | 109 +++++ app/models/event.rb | 109 +++++ app/models/merkle_node.rb | 446 ++++++++++++++++++ app/views/layouts/application.html.erb | 27 ++ app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + bin/brakeman | 7 + bin/bundle | 109 +++++ bin/dev | 2 + bin/docker-entrypoint | 14 + bin/jobs | 6 + bin/kamal | 27 ++ bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 34 ++ bin/thrust | 5 + config.ru | 8 + config/application.rb | 41 ++ config/boot.rb | 6 + config/cache.yml | 16 + config/deploy.yml | 110 +++++ config/environment.rb | 7 + config/environments/development.rb | 62 +++ config/environments/production.rb | 74 +++ config/environments/test.rb | 44 ++ .../initializers/content_security_policy.rb | 27 ++ .../initializers/filter_parameter_logging.rb | 10 + config/initializers/inflections.rb | 18 + config/locales/en.yml | 31 ++ config/puma.rb | 44 ++ config/queue.yml | 18 + config/recurring.yml | 10 + config/routes.rb | 46 ++ db/cable_schema.rb | 11 + db/cache_schema.rb | 14 + db/migrate/20240325200101_create_events.rb | 18 + ...325200102_add_extended_fields_to_events.rb | 14 + .../20240325200103_create_merkle_nodes.rb | 30 ++ db/queue_schema.rb | 129 +++++ db/schema.rb | 52 ++ db/seeds.rb | 11 + demo/publisher/README.md | 0 demo/publisher/deno.jsonc | 30 ++ demo/publisher/deno.lock | 79 ++++ demo/publisher/index.js | 65 +++ demo/subscriber/README.md | 0 demo/subscriber/deno.jsonc | 30 ++ demo/subscriber/deno.lock | 79 ++++ demo/subscriber/index.js | 108 +++++ lib/tasks/.keep | 0 .../rails/credentials/credentials.yml.tt | 5 + log/.keep | 0 public/400.html | 114 +++++ public/404.html | 114 +++++ public/406-unsupported-browser.html | 114 +++++ public/422.html | 114 +++++ public/500.html | 114 +++++ public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + rbs_collection.lock.yaml | 344 ++++++++++++++ rbs_collection.yaml | 19 + script/.keep | 0 test/application_system_test_case.rb | 16 + test/controllers/.keep | 0 test/fixtures/files/.keep | 0 test/helpers/.keep | 0 test/integration/.keep | 0 test/models/.keep | 0 test/system/.keep | 0 test/test_helper.rb | 17 + tmp/.keep | 0 vendor/.keep | 0 116 files changed, 4645 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/compose.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100755 .kamal/hooks/docker-setup.sample create mode 100755 .kamal/hooks/post-deploy.sample create mode 100755 .kamal/hooks/post-proxy-reboot.sample create mode 100755 .kamal/hooks/pre-build.sample create mode 100755 .kamal/hooks/pre-connect.sample create mode 100755 .kamal/hooks/pre-deploy.sample create mode 100755 .kamal/hooks/pre-proxy-reboot.sample create mode 100644 .kamal/secrets create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/controllers/api/application_controller.rb create mode 100644 app/controllers/api/events_controller.rb create mode 100644 app/controllers/api/home_controller.rb create mode 100644 app/controllers/api/recipients/application_controller.rb create mode 100644 app/controllers/api/recipients/events_controller.rb create mode 100644 app/controllers/api/topics/application_controller.rb create mode 100644 app/controllers/api/topics/events_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/helpers/application_helper.rb create mode 100644 app/jobs/application_job.rb create mode 100644 app/lib/digest/keccak256.rb create mode 100644 app/lib/pagy/backends/cursor.rb create mode 100644 app/lib/pagy/backends/uuid_cursor.rb create mode 100644 app/lib/pagy/cursor.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/concerns/nostr/nip1.rb create mode 100644 app/models/event.rb create mode 100644 app/models/merkle_node.rb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100755 bin/brakeman create mode 100755 bin/bundle create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/jobs create mode 100755 bin/kamal create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/cache.yml create mode 100644 config/deploy.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/migrate/20240325200101_create_events.rb create mode 100644 db/migrate/20240325200102_add_extended_fields_to_events.rb create mode 100644 db/migrate/20240325200103_create_merkle_nodes.rb create mode 100644 db/queue_schema.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 demo/publisher/README.md create mode 100644 demo/publisher/deno.jsonc create mode 100644 demo/publisher/deno.lock create mode 100644 demo/publisher/index.js create mode 100644 demo/subscriber/README.md create mode 100644 demo/subscriber/deno.jsonc create mode 100644 demo/subscriber/deno.lock create mode 100644 demo/subscriber/index.js create mode 100644 lib/tasks/.keep create mode 100644 lib/templates/rails/credentials/credentials.yml.tt create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 rbs_collection.lock.yaml create mode 100644 rbs_collection.yaml create mode 100644 script/.keep create mode 100644 test/application_system_test_case.rb create mode 100644 test/controllers/.keep create mode 100644 test/fixtures/files/.keep create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/models/.keep create mode 100644 test/system/.keep create mode 100644 test/test_helper.rb create mode 100644 tmp/.keep create mode 100644 vendor/.keep diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..6c95720 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.3.6 +FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml new file mode 100644 index 0000000..5a179c3 --- /dev/null +++ b/.devcontainer/compose.yaml @@ -0,0 +1,40 @@ +name: "messaging_relay" + +services: + rails-app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Uncomment the next line to use a non-root user for all processes. + # user: vscode + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + depends_on: + - selenium + - postgres + + selenium: + image: selenium/standalone-chromium + restart: unless-stopped + + postgres: + image: postgres:16.1 + restart: unless-stopped + networks: + - default + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + +volumes: + postgres-data: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..3e482b3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,35 @@ +// For format details, see https://containers.dev/implementors/json_reference/. +// For config options, see the README at: https://github.com/devcontainers/templates/tree/main/src/ruby +{ + "name": "messaging_relay", + "dockerComposeFile": "compose.yaml", + "service": "rails-app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, + "ghcr.io/rails/devcontainer/features/postgres-client": {} + }, + + "containerEnv": { + "CAPYBARA_SERVER_PORT": "45678", + "SELENIUM_HOST": "selenium", + "KAMAL_REGISTRY_PASSWORD": "$KAMAL_REGISTRY_PASSWORD", + "DB_HOST": "postgres" + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [3000, 5432], + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://containers.dev/implementors/json_reference/#remoteUser. + // "remoteUser": "root", + + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "bin/setup --skip-server" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d180dd2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore RBS collection files. +/.gem_rbs_collection + +# Ignore all environment files. +/.env* +!/.env*.erb +!/.env*.sample + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* + +# Ignore project's misc +/demo diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0527e6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bd6b2f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop -f github + + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + # redis: + # image: redis + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config google-chrome-stable + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Run tests + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432 + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare test test:system + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84a57a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore RBS collection files. +/.gem_rbs_collection + +# Ignore all environment files. +/.env* +!/.env*.erb +!/.env*.sample + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +!/tmp/storage/.keep + +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key +/config/credentials/*.key +/config/credentials.yml.enc +/config/credentials/*.yml.enc + +# Ignore all config files +/config/cable.yml +/config/database.yml +/config/solid_cache.yml +/config/solid_queue.yml diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 0000000..2fb07d7 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 0000000..75efafc --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 0000000..1435a67 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 0000000..f87d811 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 0000000..18e61d7 --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 0000000..1b280c7 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end +end + + +$stdout.sync = true + +puts "Checking build status..." +attempts = 0 +checks = GithubStatusChecks.new + +begin + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 0000000..061f805 --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 0000000..9a771a3 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,17 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Example of extracting secrets from 1password (or another compatible pw manager) +# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) + +# Use a GITHUB_TOKEN if private repositories are needed for the image +# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) + +# Grab the registry password from ENV +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Improve security by using a password manager. Never check config/master.key into git! +RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..c65208e --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,28 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style + +# Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +Layout/SpaceInsideArrayLiteralBrackets: + Enabled: true + EnforcedStyle: no_space + +# Checks for a newline after the final magic comment +Layout/EmptyLineAfterMagicComment: + Enabled: true + +# Looks for trailing blank lines and a final newline in the source code +Layout/TrailingEmptyLines: + Enabled: true + EnforcedStyle: final_newline + +# Looks for trailing whitespace in the source code +Layout/TrailingWhitespace: + Enabled: true + AllowInHeredoc: false + +# Always add the frozen string literal magic comment to the top of files +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..9c25013 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..19dae7a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t messaging_relay . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name messaging_relay messaging_relay + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.3.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 postgresql-client && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY Gemfile Gemfile.lock vendor ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + + + + +# Final stage for app image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R rails:rails db log tmp +USER 1000:1000 + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..b6c174f --- /dev/null +++ b/Gemfile @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Use main development branch of Rails +gem "rails", github: "rails/rails", branch: "main" +# Use postgresql as the database for Active Record +gem "pg", "~> 1.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +gem "with_advisory_lock" +gem "pagy" +gem "rbsecp256k1" +gem "keccak" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" + + # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler] + # gem "rack-mini-profiler" +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..47cd5ba --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,367 @@ +GIT + remote: https://github.com/rails/rails.git + revision: dd33918a76308cd71b17179c045a172bf5510327 + branch: main + specs: + actioncable (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + activejob (= 8.1.0.alpha) + activerecord (= 8.1.0.alpha) + activestorage (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + mail (>= 2.8.0) + actionmailer (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + actionview (= 8.1.0.alpha) + activejob (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.0.alpha) + actionview (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + activerecord (= 8.1.0.alpha) + activestorage (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.0.alpha) + activesupport (= 8.1.0.alpha) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.0.alpha) + activesupport (= 8.1.0.alpha) + globalid (>= 0.3.6) + activemodel (8.1.0.alpha) + activesupport (= 8.1.0.alpha) + activerecord (8.1.0.alpha) + activemodel (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + timeout (>= 0.4.0) + activestorage (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + activejob (= 8.1.0.alpha) + activerecord (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + marcel (~> 1.0) + activesupport (8.1.0.alpha) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + rails (8.1.0.alpha) + actioncable (= 8.1.0.alpha) + actionmailbox (= 8.1.0.alpha) + actionmailer (= 8.1.0.alpha) + actionpack (= 8.1.0.alpha) + actiontext (= 8.1.0.alpha) + actionview (= 8.1.0.alpha) + activejob (= 8.1.0.alpha) + activemodel (= 8.1.0.alpha) + activerecord (= 8.1.0.alpha) + activestorage (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + bundler (>= 1.15.0) + railties (= 8.1.0.alpha) + railties (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.2) + base64 (0.2.0) + bcrypt_pbkdf (1.1.1) + bcrypt_pbkdf (1.1.1-arm64-darwin) + bcrypt_pbkdf (1.1.1-x86_64-darwin) + benchmark (0.4.0) + bigdecimal (3.1.8) + bindex (0.8.1) + bootsnap (1.18.4) + msgpack (~> 1.2) + brakeman (6.2.2) + racc + builder (3.3.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + concurrent-ruby (1.3.4) + connection_pool (2.4.1) + crass (1.0.6) + date (3.4.0) + debug (1.9.2) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.1.4) + drb (2.2.1) + ed25519 (1.3.0) + erubi (1.13.0) + et-orbi (1.2.11) + tzinfo + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) + globalid (1.2.1) + activesupport (>= 6.1) + i18n (1.14.6) + concurrent-ruby (~> 1.0) + io-console (0.7.2) + irb (1.14.1) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.8.2) + kamal (2.3.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.2) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + keccak (1.3.1) + language_server-protocol (3.17.0.3) + logger (1.6.1) + loofah (2.23.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) + matrix (0.4.2) + mini_mime (1.1.5) + mini_portile2 (2.8.8) + minitest (5.25.2) + msgpack (1.7.5) + net-imap (0.5.1) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-scp (4.0.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.0) + net-protocol + net-ssh (7.3.0) + nio4r (2.7.4) + nokogiri (1.16.7-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.7-arm-linux) + racc (~> 1.4) + nokogiri (1.16.7-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.7-x86-linux) + racc (~> 1.4) + nokogiri (1.16.7-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.16.7-x86_64-linux) + racc (~> 1.4) + ostruct (0.6.1) + pagy (9.3.1) + parallel (1.26.3) + parser (3.3.6.0) + ast (~> 2.4.1) + racc + pg (1.5.9) + pkg-config (1.5.8) + psych (5.2.0) + stringio + public_suffix (6.0.1) + puma (6.5.0) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.1.8) + rack-session (2.0.0) + rack (>= 3.0.0) + rack-test (2.1.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + rainbow (3.1.1) + rake (13.2.1) + rbsecp256k1 (6.0.0) + mini_portile2 (~> 2.8) + pkg-config (~> 1.5) + rubyzip (~> 2.3) + rdoc (6.8.1) + psych (>= 4.0.0) + regexp_parser (2.9.2) + reline (0.5.11) + io-console (~> 0.5) + rexml (3.3.9) + rubocop (1.68.0) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.36.1) + parser (>= 3.3.1.0) + rubocop-minitest (0.36.0) + rubocop (>= 1.61, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-performance (1.23.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.27.0) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails-omakase (1.0.0) + rubocop + rubocop-minitest + rubocop-performance + rubocop-rails + ruby-progressbar (1.13.0) + rubyzip (2.3.2) + securerandom (0.3.2) + selenium-webdriver (4.27.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + solid_cable (3.0.2) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.6) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.0.2) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (~> 1.3.1) + sshkit (1.23.2) + base64 + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stringio (3.1.2) + thor (1.3.2) + thruster (0.1.9) + thruster (0.1.9-aarch64-linux) + thruster (0.1.9-arm64-darwin) + thruster (0.1.9-x86_64-darwin) + thruster (0.1.9-x86_64-linux) + timeout (0.4.2) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.6.0) + uri (1.0.2) + useragent (0.16.10) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket (1.2.11) + websocket-driver (0.7.6) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + with_advisory_lock (5.1.0) + activerecord (>= 6.1) + zeitwerk (>= 2.6) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.1) + +PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + x86-linux + x86_64-darwin + x86_64-linux + +DEPENDENCIES + bootsnap + brakeman + capybara + debug + kamal + keccak + pagy + pg (~> 1.1) + puma (>= 5.0) + rails! + rbsecp256k1 + rubocop-rails-omakase + selenium-webdriver + solid_cable + solid_cache + solid_queue + thruster + tzinfo-data + web-console + with_advisory_lock + +BUNDLED WITH + 2.5.23 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7db80e4 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# README + +This README would normally document whatever steps are necessary to get the +application up and running. + +Things you may want to cover: + +* Ruby version + +* System dependencies + +* Configuration + +* Database creation + +* Database initialization + +* How to run the test suite + +* Services (job queues, cache servers, search engines, etc.) + +* Deployment instructions + +* ... diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..d2a78aa --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..dcd7273 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1 @@ +/* Application styles */ diff --git a/app/controllers/api/application_controller.rb b/app/controllers/api/application_controller.rb new file mode 100644 index 0000000..2b81556 --- /dev/null +++ b/app/controllers/api/application_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Api + class ApplicationController < ActionController::API + include Pagy::Backends::UuidCursor + end +end diff --git a/app/controllers/api/events_controller.rb b/app/controllers/api/events_controller.rb new file mode 100644 index 0000000..8870203 --- /dev/null +++ b/app/controllers/api/events_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Api + class EventsController < ::Api::ApplicationController + def show + @event = Event.find_by!(eid: params[:id]) + + render json: { + status: "ok", + event: @event.nip1_hash, + extra: { + topic: @event.topic, + session: @event.session, + latest: { + id: @event.latest.eid, + created_at: @event.latest.created_at.to_i + }, + root_hash: @event.merkle_tree_root.calculated_hash, + inclusion_proof: @event.inclusion_proof + } + } + rescue ActiveRecord::RecordNotFound => _ex + render json: { + status: "error", + error: { + message: "Event not found" + } + }, status: :not_found + end + + def create + @event = Event.from_raw params.require(:event) + if @event.save + render json: { + status: "ok", + event: @event.nip1_hash + } + else + render json: { + status: "error", + error: { + message: "Event not saved", + data: @event.errors.full_messages + } + }, status: :unprocessable_content + end + end + + def batch_create + returns = [] + errored = nil + + params.require(:events).map do |event_params| + event_params.permit! + + event = Event.from_raw(event_params) + if event.save + returns << event + else + errored = event_params + break + end + end + + render json: { + status: "ok", + returns: returns.map(&:nip1_hash), + errored: errored + } + end + end +end diff --git a/app/controllers/api/home_controller.rb b/app/controllers/api/home_controller.rb new file mode 100644 index 0000000..91f6603 --- /dev/null +++ b/app/controllers/api/home_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Api + class HomeController < Api::ApplicationController + def index + render json: { + status: "ok" + } + end + end +end diff --git a/app/controllers/api/recipients/application_controller.rb b/app/controllers/api/recipients/application_controller.rb new file mode 100644 index 0000000..0a9521f --- /dev/null +++ b/app/controllers/api/recipients/application_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Api + module Recipients + class ApplicationController < ::Api::ApplicationController + before_action :set_recipient + + private + + def set_recipient + if params[:recipient_id].blank? + render json: { + status: "error", + error: "Recipient not found" + }, status: :not_found + end + + @recipient = params[:recipient_id] + end + end + end +end diff --git a/app/controllers/api/recipients/events_controller.rb b/app/controllers/api/recipients/events_controller.rb new file mode 100644 index 0000000..23300e2 --- /dev/null +++ b/app/controllers/api/recipients/events_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Api::Recipients + class EventsController < ::Api::Recipients::ApplicationController + def index + @pagy, @records = pagy_uuid_cursor( + Event.of_recipient(@recipient), + after: params[:after], primary_key: :eid, order: { created_at: :asc } + ) + + render json: { + status: "ok", + events: @records.map(&:nip1_hash), + pagination: { + has_more: @pagy.has_more? + } + } + end + + def latest + @event = Event.of_recipient(@recipient).order(created_at: :desc).first + + render json: { + status: "ok", + event: @event&.nip1_hash + } + end + end +end diff --git a/app/controllers/api/topics/application_controller.rb b/app/controllers/api/topics/application_controller.rb new file mode 100644 index 0000000..48cf94a --- /dev/null +++ b/app/controllers/api/topics/application_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Api + module Topics + class ApplicationController < ::Api::ApplicationController + before_action :set_topic + + private + + def set_topic + if params[:topic_id].blank? + render json: { + status: "error", + error: "Topic not found" + }, status: :not_found + end + + @topic = params[:topic_id] + end + end + end +end diff --git a/app/controllers/api/topics/events_controller.rb b/app/controllers/api/topics/events_controller.rb new file mode 100644 index 0000000..4e05f1f --- /dev/null +++ b/app/controllers/api/topics/events_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Api::Topics + class EventsController < ::Api::Topics::ApplicationController + def index + @pagy, @records = pagy_uuid_cursor( + Event.of_topic(@topic), + after: params[:after], primary_key: :eid, order: { created_at: :asc } + ) + + render json: { + status: "ok", + events: @records.map(&:nip1_hash), + pagination: { + has_more: @pagy.has_more? + } + } + end + + def latest + @event = Event.of_topic(@topic).order(created_at: :desc).first + + render json: { + status: "ok", + event: @event&.nip1_hash + } + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..9c1acb3 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..15b06f0 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +module ApplicationHelper +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..bef3959 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/lib/digest/keccak256.rb b/app/lib/digest/keccak256.rb new file mode 100644 index 0000000..e1fb80a --- /dev/null +++ b/app/lib/digest/keccak256.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Digest + class Keccak256 + def self.digest(data) + Digest::Keccak.digest(data, 256).unpack("H*").first + end + end +end diff --git a/app/lib/pagy/backends/cursor.rb b/app/lib/pagy/backends/cursor.rb new file mode 100644 index 0000000..33e4c23 --- /dev/null +++ b/app/lib/pagy/backends/cursor.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Pagy + module Backends + module Cursor + extend Pagy::Backend + + private # the whole module is private so no problem with including it in a controller + + # Return Pagy object and limit + def pagy_cursor(collection, vars = {}) + pagy = Pagy::Cursor.new(pagy_cursor_get_vars(collection, vars)) + + limit = pagy_cursor_get_limit(collection, pagy, pagy.position) + pagy.has_more = pagy_cursor_has_more?(limit, pagy) + + [pagy, limit] + end + + def pagy_cursor_get_vars(collection, vars) + pagy_get_limit_param(vars) if defined?(LimitExtra) + + vars[:arel_table] = collection.arel_table + vars[:primary_key] = collection.primary_key + vars[:backend] = "sequence" + vars + end + + def pagy_cursor_get_limit(collection, pagy, position = nil) + if position.present? + sql_comparison = pagy.arel_table[pagy.primary_key].send(pagy.comparison, position) + collection.where(sql_comparison).reorder(pagy.order).limit(pagy.limit) + else + collection.reorder(pagy.order).limit(pagy.limit) + end + end + + def pagy_cursor_has_more?(collection, pagy) + return false if collection.empty? + + next_position = collection.last[pagy.primary_key] + pagy_cursor_get_limit(collection, pagy, next_position).exists? + end + end + end +end diff --git a/app/lib/pagy/backends/uuid_cursor.rb b/app/lib/pagy/backends/uuid_cursor.rb new file mode 100644 index 0000000..b7b2f45 --- /dev/null +++ b/app/lib/pagy/backends/uuid_cursor.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class Pagy + module Backends + module UuidCursor + extend Pagy::Backend + + private # the whole module is private so no problem with including it in a controller + + # Return Pagy object and limit + def pagy_uuid_cursor(collection, vars = {}) + pagy = Pagy::Cursor.new(pagy_uuid_cursor_get_vars(collection, vars)) + limit = pagy_uuid_cursor_get_limit(collection, pagy, pagy.position) + pagy.has_more = pagy_uuid_cursor_has_more?(limit, pagy) + + return pagy, limit + end + + def pagy_uuid_cursor_get_vars(collection, vars) + pagy_get_limit_param(vars) if defined?(LimitExtra) + + vars[:arel_table] = collection.arel_table + vars[:primary_key] ||= collection.primary_key + vars[:backend] = "uuid" + vars + end + + def pagy_uuid_cursor_get_limit(collection, pagy, position = nil) + if position.present? + arel_table = pagy.arel_table + + # If the primary sort key is not "created_at" + + # Select the primary sort key + # pagy.order should be something like: + # [:created_at, :id] or [:foo_column, ..., :created_at, :id] + primary_sort_key = pagy.order.keys.detect { |order_key| ![:created_at, :id].include?(order_key.to_sym) } || :created_at + + select_previous_row = arel_table.project(arel_table[primary_sort_key]). + where(arel_table[pagy.primary_key].eq(position)) + + sql_comparison = arel_table[primary_sort_key]. + send(pagy.comparison, select_previous_row). + or( + arel_table[primary_sort_key].eq(select_previous_row). + and(arel_table[pagy.primary_key].send(pagy.comparison, position)) + ) + + collection = collection.where(sql_comparison) + end + collection.reorder(pagy.order).limit(pagy.limit) + end + + def pagy_uuid_cursor_has_more?(collection, pagy) + return false if collection.empty? + + next_position = collection.last[pagy.primary_key] + pagy_uuid_cursor_get_limit(collection, pagy, next_position).exists? + end + end + end +end diff --git a/app/lib/pagy/cursor.rb b/app/lib/pagy/cursor.rb new file mode 100644 index 0000000..c24d4c6 --- /dev/null +++ b/app/lib/pagy/cursor.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Pagy + class Cursor < Pagy + attr_reader :before, :after, :arel_table, :primary_key, :order, :comparison, :position + attr_accessor :has_more + alias_method :has_more?, :has_more + + def initialize(vars) + @vars = DEFAULT.merge(vars.delete_if { |_, v| v.nil? || v == "" }) + @limit = vars[:limit] || DEFAULT[:limit] + @before = vars[:before] + @after = vars[:after] + @arel_table = vars[:arel_table] + @primary_key = vars[:primary_key] + @reorder = vars[:order] || {} + + if @before.present? and @after.present? + raise(ArgumentError, "before and after can not be both mentioned") + end + + if vars[:backend] == "uuid" + @comparison = "lt" # arel table less than + @position = @before + @order = @reorder.any? ? @reorder : { :created_at => :desc, @primary_key => :desc } + + if @after.present? || (@reorder.present? && @reorder.values.uniq.first&.to_sym == :asc) + @comparison = "gt" # arel table greater than + @position = @after + @order = @reorder.any? ? @reorder : { :created_at => :asc, @primary_key => :asc } + end + else + @comparison = "lt" + @position = @before + @order = @reorder.reverse_merge({ @primary_key => :desc }) + + if @after.present? || (@reorder.present? && @reorder.values.uniq.first&.to_sym == :asc) + @comparison = "gt" + @position = @after + @order = @reorder.merge({ @primary_key => :asc }) + end + end + end + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..08dc537 --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/concerns/nostr/nip1.rb b/app/models/concerns/nostr/nip1.rb new file mode 100644 index 0000000..7136235 --- /dev/null +++ b/app/models/concerns/nostr/nip1.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Nostr + module Nip1 + extend ActiveSupport::Concern + + # AVAILABLE_FILTERS = SubscriptionQueryBuilder::AVAILABLE_FILTERS.map { |filter_name| /\A[a-zA-Z]\Z/.match?(filter_name) ? "##{filter_name}" : filter_name } + + KNOWN_KIND_TYPES = { + message: 1573 + } + + included do + normalizes :eid, with: ->(eid) { eid.strip.downcase } + normalizes :pubkey, with: ->(pubkey) { pubkey.strip.downcase } + normalizes :sig, with: ->(sig) { sig.strip.downcase } + + validates :eid, + presence: true, + uniqueness: true, + length: { is: 64 }, + format: { with: /\A\h+\z/ } + validates :pubkey, + presence: true, + length: { is: 64 }, + format: { with: /\A\h+\z/ } + validates :kind, + presence: true, + inclusion: { + in: KNOWN_KIND_TYPES.values + } + validates :content, + presence: true + validates :sig, + presence: true, + length: { is: 128 }, + format: { with: /\A\h+\z/ } + validate :id_must_match_payload + validate :sig_must_match_payload + + def created_at=(value) + value.is_a?(Numeric) ? super(Time.at(value)) : super(value) + end + + def serialized_nostr_event + [ + 0, + pubkey, + created_at.to_i, + kind, + tags, + content.to_s + ] + end + + def serialized_nostr_event_json + serialized_nostr_event.to_json + end + + def nip1_hash + { + id: eid, + pubkey:, + created_at: created_at.to_i, + kind:, + tags: tags, + content:, + sig: + } + end + def nip1_json + nip1_hash.to_json + end + + def computed_eid + Digest::SHA256.hexdigest(serialized_nostr_event_json) + end + + def schnorr_signature_verified? + schnorr_params = { + message: [eid].pack("H*"), + pubkey: [pubkey].pack("H*"), + sig: [sig].pack("H*") + } + Secp256k1::SchnorrSignature.from_data(schnorr_params[:sig]) + .verify( + schnorr_params[:message], + Secp256k1::XOnlyPublicKey.from_data(schnorr_params[:pubkey]) + ) + rescue Secp256k1::DeserializationError => _ex + false + end + + private + + def id_must_match_payload + unless computed_eid == eid + errors.add(:eid, "must match payload") + end + end + + def sig_must_match_payload + unless schnorr_signature_verified? + errors.add(:sig, "must match payload") + end + end + end + end +end diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 0000000..69f8b23 --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class Event < ApplicationRecord + include Nostr::Nip1 + + scope :of_pubkey, ->(pubkey) { where(pubkey:) } + scope :of_session, ->(session) { where(session:) } + scope :of_topic, ->(topic) { where(topic:) } + scope :of_recipient, ->(recipient) { where(recipient:) } + + has_one :merkle_node, dependent: :restrict_with_exception + + after_create :add_to_merkle_tree + + # A publisher must not send events in the same time which makes harder to sort them. + validates :created_at, + uniqueness: { + scope: :pubkey + }, + comparison: { + greater_than: ->(current) { + current.latest&.created_at || 0 + } + } + + validates :session, + presence: true, + length: { maximum: 4 } + + validates :topic, + length: { is: 64 }, + format: { with: /\A\h+\z/ }, + allow_nil: true + + validates :recipient, + length: { is: 64 }, + format: { with: /\A\h+\z/ }, + allow_nil: true + + before_validation do + self.session = tags.find { |tag| tag[0] == "s" }&.[](1) + self.topic = tags.find { |tag| tag[0] == "t" }&.[](1) + self.recipient = tags.find { |tag| tag[0] == "p" }&.[](1) + end + + def readonly? + persisted? + end + + def merkle_tree_hash + Digest::Keccak256.digest([pubkey, topic, session].join) + end + + def merkle_tree_root + merkle_node&.tree_root + end + + def consistency_proof + merkle_node&.consistency_proof + end + + def inclusion_proof + merkle_node&.inclusion_proof + end + + def latest + @latest ||= + Event + .of_topic(topic) + .of_pubkey(pubkey) + .of_session(session) + .order(id: :desc) + .first + end + + def reload(options = nil) + @latest = nil + super + end + + class << self + def from_raw(nip1_json) + return new unless nip1_json + + new( + eid: nip1_json.fetch("id"), + pubkey: nip1_json.fetch("pubkey"), + created_at: nip1_json.fetch("created_at"), + kind: nip1_json.fetch("kind"), + tags: nip1_json.fetch("tags"), + content: nip1_json.fetch("content"), + sig: nip1_json.fetch("sig") + ) + end + end + + private + + def add_to_merkle_tree + return if new_record? + return if MerkleNode.where(event: self).exists? + + lock_key = "add_to_merkle_tree_#{merkle_tree_hash}" + with_advisory_lock(lock_key) do + MerkleNode.push_leaves!([self]) + MerkleNode.untaint!(merkle_tree_hash) + end + end +end diff --git a/app/models/merkle_node.rb b/app/models/merkle_node.rb new file mode 100644 index 0000000..b98a5a0 --- /dev/null +++ b/app/models/merkle_node.rb @@ -0,0 +1,446 @@ +# frozen_string_literal: true + +class MerkleNode < ApplicationRecord + scope :of_tree, ->(tree_hash) { where tree_hash: } + + belongs_to :event, optional: true + + validates :tree_hash, :begin_timestamp, :end_timestamp, :level, + presence: true, + allow_blank: false + + class_attribute :hasher, default: Digest::Keccak256 + attr_accessor :parent, :children + + # 2**100 is enough for any tree at the time of writing + TRAVERSE_MAX_DEPTH = 100 + + def readonly? + persisted? && event + end + + def tree_root + MerkleNode.of_tree(tree_hash).order(level: :desc).first! + end + + def calculate_hash + self.calculated_hash ||= + if event + event.eid + else + load_children if children.nil? + + MerkleNode.calculate_hash(children.map(&:calculate_hash).join) + end + end + + def calculate_hash! + self.calculated_hash = nil + calculated_hash + end + + # A consistency proof: if current hash is consistent with a node + # + # Returns [{hash: String, reduce: Integer, is_path: Boolean}] + # + # The hash of last element in the array is the root hash of the tree. + # + # To verify (js): + # + # const stack = [] + # for (const elem of proof) { + # const hash = elem.hash + # const reduce = elem.reduce + # // if the node is in the calculation path of inclusion proof + # const is_path = elem.is_path + # assert(stack.length >= reduce) + # if (reduce > 0) { + # const children = stack.splice(-reduce) + # assert(keccak256("\x01" + children.join("")) === hash) + # } + # stack.push(hash) + # } + # assert(stack.length === 1) + # assert(stack[0] === rootHash) + # + def consistency_proof + # return [] unless event # XXX: branches do not have event + + # NOTE: assume the tree is untainted, or this method will be very complicated + + # node's timestamp represents a root in past moment. + # before that moment, we compute the inclusion. + # after that moment, we compute the consistency. + + # it is like a binary search-down + # - when node doesn't overlap with ts, we take the hash + # - when node overlaps with ts, we drill down to children + + stack = [] + traverse = ->(node, max_depth) do + if max_depth == 0 + raise "bad data: max depth reached" + end + if node.level > 0 + node.load_children + unless (1..2).cover? node.children.size + raise "bad children size: #{node.children.size} for #{node.id}" + end + node.children.each do |child| + if child.end_timestamp < end_timestamp or child.begin_timestamp > end_timestamp + # assume all nodes have calculated_hash + stack << child + else + traverse[child, max_depth - 1] + end + end + end + stack << node + end + root = MerkleNode.of_tree(tree_hash).order(level: :desc).first! + traverse[root, TRAVERSE_MAX_DEPTH] + + stack.map! do |n| + is_path = (n.end_timestamp == end_timestamp) + hash = n.calculated_hash + { id: n.id, reduce: n.children&.size || 0, hash:, is_path: } + end + stack + end + + # An (inclusion) proof: the nodes required to compute the hash of timestamp. + # + # Returns [{hash: String, reduce: Integer, is_path: Boolean}] + # + # The hash of last element in the array is the root hash of the tree. + # + # To verify (js): + # + # import { keccak256 } from "ethereum-cryptography/keccak.js" + # import { bytesToHex } from "@ethereumjs/util"; + # + # const stack = [] + # for (const elem of proof) { + # const hash = elem.hash + # const reduce = elem.reduce + # const is_path = elem.is_path # if the node is in the calculation path + # assert(stack.length >= reduce) + # if (reduce > 0) { + # const children = stack.splice(-reduce) + # const expectation = bytesToHex(keccak256(new TextEncoder().encode("\x01" + children.join("")))).slice(2) + # assert(expectation === hash) + # } + # stack.push(hash) + # } + # assert(stack.length === 1) + # assert(stack[0] === rootHash) + # + def inclusion_proof(target_event = event) + # return [] unless event + return [] unless target_event + if target_event.created_at < event.created_at + raise ArgumentError, "`target_event` must newer than the event" + end + + tree_hash = event.merkle_tree_hash + timestamp = target_event.created_at.to_i + leaf = MerkleNode.of_tree(tree_hash).find_by!(end_timestamp: timestamp, level: 0) + if leaf.nil? + return [] + end + # TODO: min_timestamp for each tree can be cached in memory, or, we just keep it 0 + min_timestamp = + MerkleNode + .of_tree(tree_hash) + .order(begin_timestamp: :asc) + .limit(1) + .pluck(:begin_timestamp) + .first + + # begin_timestamp == min_timestamp, means the node was once a root + root_at_timestamp = + MerkleNode + .of_tree(tree_hash) + .order(level: :asc) + .find_by!("begin_timestamp = ? and end_timestamp >= ?", min_timestamp, timestamp) + + # search down, until the leaf + if root_at_timestamp.level > 0 + root_at_timestamp.calculated_hash = nil + end + node = root_at_timestamp + while node.level > leaf.level + node.load_children_end_with timestamp + unless (1..2).cover? node.children.size + raise "bad children size: #{node.children.size} for #{node.id}" + end + node = node.children.last + if node.level > leaf.level + node.calculated_hash = nil + else + raise "leaf not matched" if node.id != leaf.id + end + end + + # path nodes are referencing each other. + # traverse bottom-up to get a data structure that can be serialized to json + path = [] + traverse = ->(n, max_depth) { + if max_depth == 0 + raise "bad data: max depth reached" + end + if n.children + n.children.each { |ch| traverse[ch, max_depth - 1] } + end + path << n + } + traverse[root_at_timestamp, TRAVERSE_MAX_DEPTH] + path.map! do |n| + is_path = (n.level == 0 or n.calculated_hash.nil?) + hash = (n.calculated_hash ||= MerkleNode.calculate_hash(n.children.map(&:calculated_hash).join)) + { id: n.id, reduce: n.children&.size || 0, hash:, is_path: } + end + path + end + + def self.push_leaves!(events) + return if events.empty? + + # TODO: check events' topic, session, and sorted + + # assume all events in the same tree, and ordered by timestamp + tree_hash = events.first.merkle_tree_hash + + # create leaf (nodes created from bottom-up) + to_create_nodes = events.map do |event| + { + tree_hash:, + event_id: event.id, + calculated_hash: event.eid, + begin_timestamp: event.created_at.to_i, end_timestamp: event.created_at.to_i, + level: 0, full: true + } + end + + max_timestamp = + of_tree(tree_hash) + .where("end_timestamp < ?", events.first.created_at.to_i) + .order(end_timestamp: :desc) + .limit(1) + .pluck(:end_timestamp) + .first + + root_level = 0 + if max_timestamp + # query newest nodes in previous tree, including all levels + frontiers = + of_tree(tree_hash) + .where("level > ? and end_timestamp = ?", 0, max_timestamp) + .order(level: :asc) + .pluck(:id, :full, :level) + frontiers.each do |row| + row << nil # [id, full, level, to_create_node] + root_level = row[2] + end + else + frontiers = [] + end + tainted_nodes = {} # id => [full, end_timestamp] + + min_timestamp = nil + events.each do |event| + # perform carried arithmetic in radix-2: create parallel branch aligned to full branches bottom-up + # until we fill a middle-layer branch to full + low_level = true + frontiers.map! do |(id, full, level, to_create_node)| + if low_level + if full # parallel branch + id = nil + full = false + to_create_node = { + tree_hash:, begin_timestamp: event.created_at.to_i, end_timestamp: event.created_at.to_i, level:, full: + } + to_create_nodes << to_create_node + else + full = true + low_level = false + if id + tainted_nodes[id] = [full, event.created_at.to_i] + else + to_create_node[:full] = full + to_create_node[:end_timestamp] = event.created_at.to_i + end + end + else + if id + tainted_nodes[id] = [full, event.created_at.to_i] + else + to_create_node[:end_timestamp] = event.created_at.to_i + end + end + [id, full, level, to_create_node] + end + + # all layers in frontiers are full, increase tree height + if low_level + if min_timestamp.nil? and max_timestamp + min_timestamp = + of_tree(tree_hash) + .order(begin_timestamp: :asc) + .limit(1) + .pluck(:begin_timestamp) + .first + end + if min_timestamp + # a root is always full + root_level += 1 + to_create_node = { + tree_hash:, begin_timestamp: min_timestamp, end_timestamp: event.created_at.to_i, level: root_level, full: true + } + to_create_nodes << to_create_node + frontiers << [nil, true, root_level, to_create_node] + else + # event is the first leaf, no need to add root + end + min_timestamp ||= event.created_at.to_i + end + end + + reverse_indexed_tainted_nodes = {} + tainted_nodes.each do |id, k| + (reverse_indexed_tainted_nodes[k] ||= []) << id + end + reverse_indexed_tainted_nodes.each do |(full, end_timestamp), ids| + # TODO: set updated_at to now() + where(id: ids).update_all calculated_hash: nil, full:, end_timestamp: + end + create! to_create_nodes + end + + def self.push_leaves_with_lock!(events) + lock_key = "push_leaves_#{events.first.merkle_tree_hash}" + with_advisory_lock(lock_key) do + push_leaves! events + end + end + + def self.tree_root(tree_hash) + where(tree_hash:).order(level: :desc).first! + end + + # rehash tainted nodes in a tree + def self.untaint!(tree_hash) + hash_cache_by_id = {} + + # single sql to find all nodes for update + # upcase first SELECT for proper benchmark + # TODO: in batch of 1000 + rows = connection.execute(sanitize_sql_array(["SELECT id, + (select json_build_object('ids', json_agg(n.id), 'hashes', json_agg(n.calculated_hash)) from merkle_nodes n where + n.tree_hash = ? and n.level = m.level - 1 and (n.begin_timestamp = m.begin_timestamp or n.end_timestamp = m.end_timestamp)) as children + from merkle_nodes m where m.tree_hash = ? and m.calculated_hash is null order by m.level asc", tree_hash, tree_hash])) + rows.each do |row| + parent_id = row["id"] + children = JSON.parse row["children"] + raise "bad children size: #{children["ids"].size} for #{row["id"]}" if children["ids"].size > 2 + children_hashes = children["ids"].zip(children["hashes"]).to_a.sort_by(&:first).map do |(child_id, child_hash)| + child_hash || hash_cache_by_id[child_id] + end.join + h = calculate_hash(children_hashes) + where(id: parent_id).update_all calculated_hash: h + hash_cache_by_id[parent_id] = h + end + end + + def self.calculate_hash(s) + MerkleNode.hasher.digest("\x01#{s}") + end + + def load_parent + self.parent = MerkleNode + .of_tree(tree_hash) + .find_by("level = ? and (begin_timestamp = ? or end_timestamp = ?)", level + 1, begin_timestamp, end_timestamp) + end + + def load_children + self.children = MerkleNode + .of_tree(tree_hash) + .where( + "level = ? and (begin_timestamp = ? or end_timestamp = ?)", + level - 1, begin_timestamp, end_timestamp + ) + .order(begin_timestamp: :asc) + .to_a + end + + def load_children_end_with(child_end_timestamp) + self.children = MerkleNode + .of_tree(tree_hash) + .where( + "level = ? and (begin_timestamp = ? or end_timestamp = ?) and begin_timestamp <= ?", + level - 1, begin_timestamp, end_timestamp, child_end_timestamp + ) + .order(begin_timestamp: :asc) + .to_a + end + + def load_ancestors + # TODO: with recursive CTE + node = self + while node + node.load_parent + node = node.parent + end + end + + def load_descendants + if level == 0 + return [] + end + nodes = MerkleNode + .of_tree(tree_hash) + .where( + "begin_timestamp >= ? and end_timestamp <= ? and level < ?", + begin_timestamp, end_timestamp, level + ) + .order(level: :asc, begin_timestamp: :asc) + .to_a + nodes_by_level = nodes.group_by(&:level).sort_by(&:first).map(&:last) + nodes_by_level.each_cons(2) do |leafs, branches| + branches_by_begin_timestamp = branches.group_by(&:begin_timestamp) + branches_by_end_timestamp = branches.group_by(&:end_timestamp) + leafs.each do |child| + parent = branches_by_begin_timestamp[child.begin_timestamp]&.first + parent ||= branches_by_end_timestamp[child.end_timestamp]&.first + (parent.children ||= []) << child + end + end + self.children = nodes_by_level.last + nodes # nodes in bottom-up order + end + + def to_dot_digraph_label + "#{id}:#{calculated_hash}" + end + + def to_dot_digraph + nodes = load_descendants + nodes << self + out = ["digraph G {\n"] + nodes.each do |d| + if d.level > 0 + d.children.each do |c| + out << " \"#{d.id}\" -> \"#{c.id}\"\n" + end + end + out << " \"#{d.id}\" [label=\"#{d.to_dot_digraph_label}\"]\n" + end + out << "}\n" + out.join + end + + # truncate old nodes + def self.truncate(_before_id) + raise NotImplementedError + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..39ccdc7 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,27 @@ + + + + <%= content_for(:title) || "Messaging Relay" %> + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag "application" %> + + + + <%= yield %> + + diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..e6e4be7 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "MessagingRelay", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "MessagingRelay.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundle b/bin/bundle new file mode 100755 index 0000000..50da5fd --- /dev/null +++ b/bin/bundle @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..5f91c20 --- /dev/null +++ b/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..57567d6 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,14 @@ +#!/bin/bash -e + +# Enable jemalloc for reduced memory usage and latency. +if [ -z "${LD_PRELOAD+x}" ]; then + LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) + export LD_PRELOAD +fi + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 0000000..cbe59b9 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 0000000..5a20504 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..be3db3c --- /dev/null +++ b/bin/setup @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 0000000..36bde2d --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..2e03084 --- /dev/null +++ b/config.ru @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..93f6bb5 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +# require "active_storage/engine" +require "action_controller/railtie" +# require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +require "action_view/railtie" +require "action_cable/engine" +require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module MessagingRelay + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..aef6d03 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 0000000..19d4908 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,16 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..efade26 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,110 @@ +# Name of your application. Used to uniquely configure containers. +service: messaging_relay + +# Name of the container image. +image: your-user/messaging_relay + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. +# +# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +proxy: + ssl: true + host: app.example.com + +# Credentials for your image host. +registry: + # Specify the registry server, if you're not using Docker Hub + # server: registry.digitalocean.com / ghcr.io / ... + username: your-user + + # Always use an access token rather than real password when possible. + password: + - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + SOLID_QUEUE_IN_PUMA: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # Match this to any external database server to configure Active Record correctly + # Use messaging_relay-db for a db accessory server on same machine via local kamal docker network. + # DB_HOST: 192.168.0.2 + + # Log everything from Rails + # RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole" + + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: 3.3.6 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: redis:7.0 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..7df99e8 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..5873dba --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..89f10fd --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [:request_id] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [:id] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..cecb83d --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..35ab3fd --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..497ac13 --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..9e049dc --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..284ca2f --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments. +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 0000000..9eace59 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 0000000..d045b19 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,10 @@ +# production: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..3e8f047 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + # root "posts#index" + + namespace :api do + root to: "home#index" + + resources :topics, only: [] do + scope module: :topics do + resources :events, only: %i[index] do + collection do + get "latest" + end + end + end + end + + resources :recipients, only: [] do + scope module: :recipients do + resources :events, only: %i[index] do + collection do + get "latest" + end + end + end + end + + resources :events, only: %i[show create] do + collection do + post "batch", to: "events#batch_create" + end + end + end +end diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 0000000..72e6910 --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[8.0].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 0000000..e9a331b --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +ActiveRecord::Schema[8.0].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20240325200101_create_events.rb b/db/migrate/20240325200101_create_events.rb new file mode 100644 index 0000000..058d02b --- /dev/null +++ b/db/migrate/20240325200101_create_events.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateEvents < ActiveRecord::Migration[8.0] + def change + create_table :events do |t| + t.string :eid, null: false, index: { unique: true } + t.string :pubkey, null: false, index: true + t.integer :kind, null: false + t.jsonb :tags, array: true, default: [] + t.string :content, null: false + t.string :sig, null: false + + t.datetime :created_at, null: false + + t.index %i[pubkey created_at], unique: true + end + end +end diff --git a/db/migrate/20240325200102_add_extended_fields_to_events.rb b/db/migrate/20240325200102_add_extended_fields_to_events.rb new file mode 100644 index 0000000..2d3a080 --- /dev/null +++ b/db/migrate/20240325200102_add_extended_fields_to_events.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddExtendedFieldsToEvents < ActiveRecord::Migration[8.0] + def change + change_table :events, id: :string do |t| + t.string :session, null: false + + t.string :topic, null: true, index: true + t.string :recipient, null: true, index: true + + t.index %i[pubkey session] + end + end +end diff --git a/db/migrate/20240325200103_create_merkle_nodes.rb b/db/migrate/20240325200103_create_merkle_nodes.rb new file mode 100644 index 0000000..16729cb --- /dev/null +++ b/db/migrate/20240325200103_create_merkle_nodes.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class CreateMerkleNodes < ActiveRecord::Migration[8.0] + def change + # 创建顺序:parent to children + create_table :merkle_nodes do |t| + t.string :tree_hash, null: false # 一棵树的唯一标识 + t.references :event, foreign_key: true # 如果是叶子节点,那么将关联一个对应的 Event + + t.string :calculated_hash # 计算好的哈希, 可以延迟计算,避免相互依赖 + + # 对应 Event 的 timestamp,用于排序,时间早的为树的左节点,晚的为右节点, parent 包含最新子节点的 timestamp + # 每棵子树都是连续的一系列节点,只需要记录开始和结束节点 timestamp + # 通过 timestamp range 可以: + # - 查询所有后代 + # - 查询直接父 + # - 查询所有祖先 + t.integer :begin_timestamp, null: false # begin_timestamp = 0 的为创世节点 (同 level 没有更老的了) + t.integer :end_timestamp, null: false + + t.boolean :full, null: false, default: false # 节点是否已满, 创建时未满,添加子节点时设为满 + t.integer :level, null: false # 叶子 level=0。任意 parent.level = child.level + 1 + + t.index :tree_hash + t.index :begin_timestamp + t.index :end_timestamp + t.index :calculated_hash, where: "calculated_hash is null" + end + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 0000000..c9ef1cd --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,129 @@ +ActiveRecord::Schema[8.0].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..c75821a --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,52 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2024_03_25_200103) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "events", force: :cascade do |t| + t.string "eid", null: false + t.string "pubkey", null: false + t.integer "kind", null: false + t.jsonb "tags", default: [], array: true + t.string "content", null: false + t.string "sig", null: false + t.datetime "created_at", null: false + t.string "session", null: false + t.string "topic" + t.string "recipient" + t.index ["eid"], name: "index_events_on_eid", unique: true + t.index ["pubkey", "created_at"], name: "index_events_on_pubkey_and_created_at", unique: true + t.index ["pubkey", "session"], name: "index_events_on_pubkey_and_session" + t.index ["pubkey"], name: "index_events_on_pubkey" + t.index ["recipient"], name: "index_events_on_recipient" + t.index ["topic"], name: "index_events_on_topic" + end + + create_table "merkle_nodes", force: :cascade do |t| + t.string "tree_hash", null: false + t.bigint "event_id" + t.string "calculated_hash" + t.integer "begin_timestamp", null: false + t.integer "end_timestamp", null: false + t.boolean "full", default: false, null: false + t.integer "level", null: false + t.index ["begin_timestamp"], name: "index_merkle_nodes_on_begin_timestamp" + t.index ["calculated_hash"], name: "index_merkle_nodes_on_calculated_hash", where: "(calculated_hash IS NULL)" + t.index ["end_timestamp"], name: "index_merkle_nodes_on_end_timestamp" + t.index ["event_id"], name: "index_merkle_nodes_on_event_id" + t.index ["tree_hash"], name: "index_merkle_nodes_on_tree_hash" + end + + add_foreign_key "merkle_nodes", "events" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..0f16211 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/demo/publisher/README.md b/demo/publisher/README.md new file mode 100644 index 0000000..e69de29 diff --git a/demo/publisher/deno.jsonc b/demo/publisher/deno.jsonc new file mode 100644 index 0000000..3999670 --- /dev/null +++ b/demo/publisher/deno.jsonc @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "lib": ["deno.window"], + "strict": true + }, + "lint": { + "files": { + "include": ["./"], + "exclude": [] + }, + "rules": { + "tags": ["recommended"], + "include": [], + "exclude": ["no-explicit-any"] + } + }, + "fmt": { + "files": { + "include": ["./"], + "exclude": [] + }, + "options": { + "useTabs": false, + "lineWidth": 120, + "indentWidth": 2, + "singleQuote": false, + "proseWrap": "preserve" + } + } +} \ No newline at end of file diff --git a/demo/publisher/deno.lock b/demo/publisher/deno.lock new file mode 100644 index 0000000..a890fcc --- /dev/null +++ b/demo/publisher/deno.lock @@ -0,0 +1,79 @@ +{ + "version": "4", + "specifiers": { + "jsr:@std/cli@*": "1.0.7", + "npm:@noble/hashes@*": "1.5.0", + "npm:nostr-tools@*": "2.10.4" + }, + "jsr": { + "@std/cli@1.0.7": { + "integrity": "98359df9df586a69015ba570305183b0cb9e7d53c05ea2016ef9a3e77e82c7cd" + } + }, + "npm": { + "@noble/ciphers@0.5.3": { + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==" + }, + "@noble/curves@1.1.0": { + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "dependencies": [ + "@noble/hashes@1.3.1" + ] + }, + "@noble/curves@1.2.0": { + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": [ + "@noble/hashes@1.3.2" + ] + }, + "@noble/hashes@1.3.1": { + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==" + }, + "@noble/hashes@1.3.2": { + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" + }, + "@noble/hashes@1.5.0": { + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==" + }, + "@scure/base@1.1.1": { + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==" + }, + "@scure/bip32@1.3.1": { + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "dependencies": [ + "@noble/curves@1.1.0", + "@noble/hashes@1.3.2", + "@scure/base" + ] + }, + "@scure/bip39@1.2.1": { + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "dependencies": [ + "@noble/hashes@1.3.2", + "@scure/base" + ] + }, + "nostr-tools@2.10.4": { + "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", + "dependencies": [ + "@noble/ciphers", + "@noble/curves@1.2.0", + "@noble/hashes@1.3.1", + "@scure/base", + "@scure/bip32", + "@scure/bip39", + "nostr-wasm" + ] + }, + "nostr-wasm@0.1.0": { + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==" + } + }, + "redirects": { + "https://deno.land/x/sleep/mod.ts": "https://deno.land/x/sleep@v1.3.0/mod.ts" + }, + "remote": { + "https://deno.land/x/sleep@v1.3.0/mod.ts": "e9955ecd3228a000e29d46726cd6ab14b65cf83904e9b365f3a8d64ec61c1af3", + "https://deno.land/x/sleep@v1.3.0/sleep.ts": "b6abaca093b094b0c2bba94f287b19a60946a8d15764d168f83fcf555f5bb59e" + } +} diff --git a/demo/publisher/index.js b/demo/publisher/index.js new file mode 100644 index 0000000..5b71f37 --- /dev/null +++ b/demo/publisher/index.js @@ -0,0 +1,65 @@ +import { parseArgs } from "jsr:@std/cli/parse-args"; +import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent } from "npm:nostr-tools/pure"; +import { bytesToHex, hexToBytes } from "npm:@noble/hashes/utils"; +import { sleep } from "https://deno.land/x/sleep/mod.ts" + +const parsedArgs = parseArgs(Deno.args, { + string: [ + "sk", + "rpc", + "to", + ], + boolean: [ + "dry" + ], + default: { + dry: false, + sk: "c885648cc3e4c94fe00b74111247d15ebe35640f7973d8b9f839ced49e3706d5", + rpc: "http://127.0.0.1:3000", + to: "6f7bb11c04d792784c9dfcb4246e9afc0d6a7eae549531c2fce51adf09b2887e" + } +}); + +if (parsedArgs.sk.trim().length === 0) { + console.log(`\`--sk \` is required, example: \`--sk ${bytesToHex(generateSecretKey())}\``); + Deno.exit(1); +} +const sk = hexToBytes(parsedArgs.sk.trim()); +console.log(`Public Key: ${getPublicKey(sk)}`); + +if (parsedArgs.to.trim().length === 0) { + console.log(`\`--to \` is required`); + Deno.exit(1); +} +const toPubKey = parsedArgs.to; + + +try { + let event = finalizeEvent({ + kind: 1573, + created_at: Math.floor(Date.now() / 1000), + tags: [["s", "0"], ["p", toPubKey]], + content: 'Ping', + }, sk) + + console.log(JSON.stringify(event)); + + if (!parsedArgs.dry) { + const resp = await fetch(`${parsedArgs.rpc}/api/events`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({event}), + }); + if (resp.status >= 200 && resp.status < 500) { + console.log(await resp.json()); + } else { + console.error(await resp.text()); + } + } +} catch (ex) { + console.error(ex); + Deno.exit(1); +} +Deno.exit(0); diff --git a/demo/subscriber/README.md b/demo/subscriber/README.md new file mode 100644 index 0000000..e69de29 diff --git a/demo/subscriber/deno.jsonc b/demo/subscriber/deno.jsonc new file mode 100644 index 0000000..3999670 --- /dev/null +++ b/demo/subscriber/deno.jsonc @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "lib": ["deno.window"], + "strict": true + }, + "lint": { + "files": { + "include": ["./"], + "exclude": [] + }, + "rules": { + "tags": ["recommended"], + "include": [], + "exclude": ["no-explicit-any"] + } + }, + "fmt": { + "files": { + "include": ["./"], + "exclude": [] + }, + "options": { + "useTabs": false, + "lineWidth": 120, + "indentWidth": 2, + "singleQuote": false, + "proseWrap": "preserve" + } + } +} \ No newline at end of file diff --git a/demo/subscriber/deno.lock b/demo/subscriber/deno.lock new file mode 100644 index 0000000..a890fcc --- /dev/null +++ b/demo/subscriber/deno.lock @@ -0,0 +1,79 @@ +{ + "version": "4", + "specifiers": { + "jsr:@std/cli@*": "1.0.7", + "npm:@noble/hashes@*": "1.5.0", + "npm:nostr-tools@*": "2.10.4" + }, + "jsr": { + "@std/cli@1.0.7": { + "integrity": "98359df9df586a69015ba570305183b0cb9e7d53c05ea2016ef9a3e77e82c7cd" + } + }, + "npm": { + "@noble/ciphers@0.5.3": { + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==" + }, + "@noble/curves@1.1.0": { + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "dependencies": [ + "@noble/hashes@1.3.1" + ] + }, + "@noble/curves@1.2.0": { + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": [ + "@noble/hashes@1.3.2" + ] + }, + "@noble/hashes@1.3.1": { + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==" + }, + "@noble/hashes@1.3.2": { + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" + }, + "@noble/hashes@1.5.0": { + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==" + }, + "@scure/base@1.1.1": { + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==" + }, + "@scure/bip32@1.3.1": { + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "dependencies": [ + "@noble/curves@1.1.0", + "@noble/hashes@1.3.2", + "@scure/base" + ] + }, + "@scure/bip39@1.2.1": { + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "dependencies": [ + "@noble/hashes@1.3.2", + "@scure/base" + ] + }, + "nostr-tools@2.10.4": { + "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", + "dependencies": [ + "@noble/ciphers", + "@noble/curves@1.2.0", + "@noble/hashes@1.3.1", + "@scure/base", + "@scure/bip32", + "@scure/bip39", + "nostr-wasm" + ] + }, + "nostr-wasm@0.1.0": { + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==" + } + }, + "redirects": { + "https://deno.land/x/sleep/mod.ts": "https://deno.land/x/sleep@v1.3.0/mod.ts" + }, + "remote": { + "https://deno.land/x/sleep@v1.3.0/mod.ts": "e9955ecd3228a000e29d46726cd6ab14b65cf83904e9b365f3a8d64ec61c1af3", + "https://deno.land/x/sleep@v1.3.0/sleep.ts": "b6abaca093b094b0c2bba94f287b19a60946a8d15764d168f83fcf555f5bb59e" + } +} diff --git a/demo/subscriber/index.js b/demo/subscriber/index.js new file mode 100644 index 0000000..f3eca52 --- /dev/null +++ b/demo/subscriber/index.js @@ -0,0 +1,108 @@ +import { parseArgs } from "jsr:@std/cli/parse-args"; +import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent } from "npm:nostr-tools/pure"; +import { bytesToHex, hexToBytes } from "npm:@noble/hashes/utils"; +import { sleep } from "https://deno.land/x/sleep/mod.ts" + +const parsedArgs = parseArgs(Deno.args, { + string: [ + "sk", + "rpc", + ], + boolean: [ + "dry" + ], + default: { + dry: false, + sk: "", + rpc: "http://127.0.0.1:3000", + } +}); + +if (parsedArgs.sk.trim().length === 0) { + console.log(`\`--sk \` is missing, example: \`--sk ${bytesToHex(generateSecretKey())}\``); + Deno.exit(1); +} +const sk = hexToBytes(parsedArgs.sk.trim()); +const pk = getPublicKey(sk); +console.log(`Public Key: ${pk}`); + +let latestEventId = null; +let resp + +resp = await fetch(`${parsedArgs.rpc}/api/recipients/${pk}/events/latest`, { + method: "GET", + headers: { + "Content-Type": "application/json", + } +}); +if (resp.status === 200) { + const latestEvent = (await resp.json()).event + latestEventId = latestEvent ? latestEvent.id : null; +} else { + console.error(await resp.text()); + Deno.exit(1) +} + +while (true) { + try { + let url = `${parsedArgs.rpc}/api/recipients/${pk}/events`; + if (latestEventId) { + url = `${url}?after=${latestEventId}` + } + // console.log(url); + resp = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + } + }); + if (resp.status !== 200) { + console.error(await resp.text()); + await sleep(2); + continue; + } + + const events = (await resp.json()).events ?? []; + if (events.length > 0) { + latestEventId = events[events.length - 1].id + } + + const pingEvents = events.filter((e) => e.content === "Ping") + if (pingEvents.length === 0) { + console.log("No new ping event"); + await sleep(2); + continue; + } + + const pongEvents = pingEvents.map((e) => { + return finalizeEvent({ + kind: 1573, + created_at: Math.floor(Date.now() / 1000), + tags: [["s", "0"], ["p", e.pubkey]], + content: 'Pong', + }, sk) + }) + + resp = await fetch(`${parsedArgs.rpc}/api/events/batch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + events: pongEvents + }), + }); + if (resp.status === 200) { + console.log(await resp.json()); + } else { + console.error(await resp.text()); + } + + await sleep(2) + } catch (ex) { + console.error(ex); + Deno.exit(1); + } +} + +Deno.exit(0); diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/lib/templates/rails/credentials/credentials.yml.tt b/lib/templates/rails/credentials/credentials.yml.tt new file mode 100644 index 0000000..4b22485 --- /dev/null +++ b/lib/templates/rails/credentials/credentials.yml.tt @@ -0,0 +1,5 @@ +# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies. +secret_key_base: <%= SecureRandom.hex(64) %> + +# Used as the base secret for ActiveStorage MessageVerifiers in Rails. +storage_key_base: <%= SecureRandom.hex(64) %> diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/400.html b/public/400.html new file mode 100644 index 0000000..282dbc8 --- /dev/null +++ b/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..c0670bc --- /dev/null +++ b/public/404.html @@ -0,0 +1,114 @@ + + + + + + + The page you were looking for doesn’t exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 0000000..9532a9c --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,114 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..8bcf060 --- /dev/null +++ b/public/422.html @@ -0,0 +1,114 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..d77718c --- /dev/null +++ b/public/500.html @@ -0,0 +1,114 @@ + + + + + + + We’re sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We’re sorry, but something went wrong.
If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..04b34bf --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/rbs_collection.lock.yaml b/rbs_collection.lock.yaml new file mode 100644 index 0000000..0ad23ce --- /dev/null +++ b/rbs_collection.lock.yaml @@ -0,0 +1,344 @@ +--- +path: ".gem_rbs_collection" +gems: +- name: actioncable + version: '7.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: actionmailer + version: '7.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: actionpack + version: '7.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: actiontext + version: '7.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: actionview + version: '6.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: activejob + version: '6.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: activemodel + version: '7.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: activerecord + version: '7.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: activestorage + version: '7.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: activesupport + version: '7.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: addressable + version: '2.8' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: base64 + version: '0' + source: + type: stdlib +- name: benchmark + version: '0' + source: + type: stdlib +- name: bigdecimal + version: '0' + source: + type: stdlib +- name: cgi + version: '0' + source: + type: stdlib +- name: concurrent-ruby + version: '1.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: connection_pool + version: '2.4' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: date + version: '0' + source: + type: stdlib +- name: dbm + version: '0' + source: + type: stdlib +- name: delegate + version: '0' + source: + type: stdlib +- name: erb + version: '0' + source: + type: stdlib +- name: fileutils + version: '0' + source: + type: stdlib +- name: globalid + version: '1.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: i18n + version: '1.10' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: io-console + version: '0' + source: + type: stdlib +- name: logger + version: '0' + source: + type: stdlib +- name: mail + version: '2.8' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: marcel + version: '1.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: mini_mime + version: '0.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: minitest + version: '0' + source: + type: stdlib +- name: monitor + version: '0' + source: + type: stdlib +- name: mutex_m + version: '0' + source: + type: stdlib +- name: net-protocol + version: '0' + source: + type: stdlib +- name: net-smtp + version: '0' + source: + type: stdlib +- name: nokogiri + version: '1.11' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: openssl + version: '0' + source: + type: stdlib +- name: pathname + version: '0' + source: + type: stdlib +- name: pstore + version: '0' + source: + type: stdlib +- name: psych + version: '0' + source: + type: stdlib +- name: rack + version: '2.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rails-dom-testing + version: '2.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rails-html-sanitizer + version: '1.6' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: railties + version: '6.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rake + version: '13.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rdoc + version: '0' + source: + type: stdlib +- name: regexp_parser + version: '2.8' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rubyzip + version: '2.3' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: securerandom + version: '0' + source: + type: stdlib +- name: singleton + version: '0' + source: + type: stdlib +- name: socket + version: '0' + source: + type: stdlib +- name: tempfile + version: '0' + source: + type: stdlib +- name: thor + version: '1.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: time + version: '0' + source: + type: stdlib +- name: timeout + version: '0' + source: + type: stdlib +- name: tsort + version: '0' + source: + type: stdlib +- name: tzinfo + version: '2.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: b438fcecafb7fe0faad88c2bf3b8eec17d83e4d9 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: uri + version: '0' + source: + type: stdlib +gemfile_lock_path: Gemfile.lock diff --git a/rbs_collection.yaml b/rbs_collection.yaml new file mode 100644 index 0000000..66e30ec --- /dev/null +++ b/rbs_collection.yaml @@ -0,0 +1,19 @@ +# Download sources +sources: + - type: git + name: ruby/gem_rbs_collection + remote: https://github.com/ruby/gem_rbs_collection.git + revision: main + repo_dir: gems + +# You can specify local directories as sources also. +# - type: local +# path: path/to/your/local/repository + +# A directory to install the downloaded RBSs +path: .gem_rbs_collection + +# gems: +# # If you want to avoid installing rbs files for gems, you can specify them here. +# - name: GEM_NAME +# ignore: true diff --git a/script/.keep b/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 0000000..744f47e --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + if ENV["CAPYBARA_SERVER_PORT"] + served_by host: "rails-app", port: ENV["CAPYBARA_SERVER_PORT"] + + driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400], options: { + browser: :remote, + url: "http://#{ENV["SELENIUM_HOST"]}:4444" + } + else + driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400] + end +end diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/system/.keep b/test/system/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..a64c49d --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end +end diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29