diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..28983fc --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "Bash(npm run lint)", + "Bash(npm run:*)", + "Bash(rm:*)", + "Bash(bundle:*)", + "Bash(find:*)", + "Bash(bin/rails:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "WebFetch(domain:pinstr.co)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 038861c..917717d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -42,7 +42,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -77,7 +77,7 @@ jobs: run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libjemalloc2 libvips postgresql-client - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.gitignore b/.gitignore index ec817fa..8bd7e07 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,8 @@ # Ignore master key for decrypting credentials and more. /config/master.key -./node_modules +/node_modules todo.md + +/app/assets/builds/* +!/app/assets/builds/.keep diff --git a/.kamal/secrets b/.kamal/secrets index 9a771a3..202f8ca 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -12,6 +12,8 @@ # Grab the registry password from ENV KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD - +RAILS_MASTER_KEY=$(cat config/master.key) +POSTGRES_PASSWORD=$POSTGRES_PASSWORD +DATABASE_URL=$DATABASE_URL # Improve security by using a password manager. Never check config/master.key into git! RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/Gemfile b/Gemfile index e7c9401..5602f3d 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 8.0.0" +gem "rails", "~> 8.0.2" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" # Use postgresql as the database for Active Record @@ -29,10 +29,10 @@ gem "solid_queue" gem "solid_cable" # Reduces boot times through caching; required in config/boot.rb -gem "bootsnap", require: false +gem 'bootsnap', require: false, git: 'https://github.com/midnight-wonderer/ruby-bootsnap.git', branch: 'bug/test-forks-before-usage' # Deploy this application anywhere as a Docker container [https://kamal-deploy.org] -gem "kamal", require: false +gem "kamal", ">= 2.7", require: false # Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] gem "thruster", require: false @@ -40,7 +40,7 @@ gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # gem "image_processing", "~> 1.2" -gem "nostr_ruby" +gem 'nostr_ruby', git: 'https://github.com/zeroxbob/nostr-ruby.git' gem "websocket-client-simple" group :development, :test do @@ -67,3 +67,5 @@ group :test do gem "capybara" gem "capybara-playwright-driver" end + +gem "tailwindcss-rails", "~> 4.2" diff --git a/Gemfile.lock b/Gemfile.lock index 9d0603c..3cc8eda 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,23 @@ +GIT + remote: https://github.com/midnight-wonderer/ruby-bootsnap.git + revision: 0e0df3395d5ee25713cb47e843236fcaded209f1 + branch: bug/test-forks-before-usage + specs: + bootsnap (1.18.4) + msgpack (~> 1.2) + +GIT + remote: https://github.com/zeroxbob/nostr-ruby.git + revision: d83273332ee991620481379b078141b1738ba6b6 + specs: + nostr_ruby (0.3.0) + base64 (>= 0.2.0) + bech32 (~> 1.4.0) + bip-schnorr (~> 0.4.0) + faye-websocket (~> 0.11) + json (~> 2.6.2) + unicode-emoji (~> 3.3.1) + GEM remote: https://rubygems.org/ specs: @@ -75,10 +95,8 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) - base64 (0.1.2) + base64 (0.3.0) bcrypt_pbkdf (1.1.1) - bcrypt_pbkdf (1.1.1-arm64-darwin) - bcrypt_pbkdf (1.1.1-x86_64-darwin) bech32 (1.4.2) thor (>= 1.1.0) benchmark (0.4.0) @@ -86,8 +104,6 @@ GEM bindex (0.8.1) bip-schnorr (0.4.0) ecdsa (~> 1.2.0) - bootsnap (1.18.4) - msgpack (~> 1.2) brakeman (7.0.2) racc builder (3.3.0) @@ -113,7 +129,7 @@ GEM irb (~> 1.10) reline (>= 0.3.8) diff-lcs (1.6.1) - dotenv (2.8.1) + dotenv (3.1.8) drb (2.2.1) dry-configurable (1.3.0) dry-core (~> 1.1) @@ -145,7 +161,7 @@ GEM dry-logic (~> 1.4) zeitwerk (~> 2.6) ecdsa (1.2.0) - ed25519 (1.3.0) + ed25519 (1.4.0) erubi (1.13.1) et-orbi (1.2.11) tzinfo @@ -157,9 +173,9 @@ GEM json (~> 2.0) mime-types (~> 3.4) rack (~> 3.1) - faye-websocket (0.11.3) + faye-websocket (0.11.4) eventmachine (>= 0.12.0) - websocket-driver (>= 0.5.1) + websocket-driver (>= 0.5.1, < 0.8.0) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -180,16 +196,17 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) json (2.6.3) - kamal (1.3.0) + kamal (2.7.0) activesupport (>= 7.0) + base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) concurrent-ruby (~> 1.2) - dotenv (~> 2.8) - ed25519 (~> 1.2) - net-ssh (~> 7.0) - sshkit (~> 1.21) - thor (~> 1.2) - zeitwerk (~> 2.5) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.4) lint_roller (1.1.0) logger (1.7.0) @@ -243,14 +260,7 @@ GEM racc (~> 1.4) nokogiri (1.18.7-x86_64-linux-musl) racc (~> 1.4) - nostr_ruby (0.3.0) - base64 (~> 0.1.1) - bech32 (~> 1.4.0) - bip-schnorr (~> 0.4.0) - faye-websocket (~> 0.11) - json (~> 2.6.2) - unicode-emoji (~> 3.3.1) - ostruct (0.6.1) + ostruct (0.6.2) parallel (1.27.0) parser (3.3.8.0) ast (~> 2.4.1) @@ -396,6 +406,16 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.6) + tailwindcss-rails (4.2.3) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.1.8) + tailwindcss-ruby (4.1.8-aarch64-linux-gnu) + tailwindcss-ruby (4.1.8-aarch64-linux-musl) + tailwindcss-ruby (4.1.8-arm64-darwin) + tailwindcss-ruby (4.1.8-x86_64-darwin) + tailwindcss-ruby (4.1.8-x86_64-linux-gnu) + tailwindcss-ruby (4.1.8-x86_64-linux-musl) thor (1.3.2) thruster (0.1.12) thruster (0.1.12-aarch64-linux) @@ -450,26 +470,27 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - bootsnap + bootsnap! brakeman capybara capybara-playwright-driver debug importmap-rails jbuilder - kamal - nostr_ruby + kamal (>= 2.7) + nostr_ruby! pg (~> 1.1) propshaft pry puma (>= 5.0) - rails (~> 8.0.0) + rails (~> 8.0.2) rspec-rails (~> 7.0) rubocop-rails-omakase solid_cable solid_cache solid_queue stimulus-rails + tailwindcss-rails (~> 4.2) thruster tidewave turbo-rails diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..da151fe --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +web: bin/rails server +css: bin/rails tailwindcss:watch diff --git a/README.md b/README.md index 7db80e4..d51666c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,8 @@ # README -This README would normally document whatever steps are necessary to get the -application up and running. +A Nostr app to bookmark URLs. -Things you may want to cover: +- The user signs in and authenticates through their Nostr extension. +- Any bookmark that is added is broadcast to one or multiple Nostr relays. +- Bookmarks are sent as Nostr notes following the format defined in NIP-B0. -* 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/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css deleted file mode 100644 index 3214e3d..0000000 --- a/app/assets/stylesheets/application.css +++ /dev/null @@ -1,383 +0,0 @@ -/* - * This is a manifest file that'll be compiled into application.css. - * - * With Propshaft, assets are served efficiently without preprocessing steps. You can still include - * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard - * cascading order, meaning styles declared later in the document or manifest will override earlier ones, - * depending on specificity. - * - * Consider organizing styles into separate files for maintainability. - */ - -/* Import bookmarklet styles */ -@import "bookmarklet.css"; - -/* CSS Reset and Base Styles */ -* { - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - line-height: 1.6; - color: #333; - margin: 0; - padding: 0; - background-color: #f8f9fa; -} - -/* Layout */ -.container { - max-width: 1200px; - margin: 0 auto; - padding: 0 1.5rem; -} - -.main-content { - background: white; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - padding: 2rem; - margin: 2rem 0; -} - -/* Header Styles */ -header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 1rem 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.header-content { - display: flex; - justify-content: space-between; - align-items: center; - max-width: 1200px; - margin: 0 auto; - padding: 0 1.5rem; -} - -.header-content h1 { - margin: 0; - font-size: 1.8rem; - font-weight: 600; -} - -.header-content h1 a { - text-decoration: none; - color: white; -} - -.nav-links { - display: flex; - gap: 1.5rem; - align-items: center; -} - -.nav-links a, -.nav-links button { - color: rgba(255, 255, 255, 0.9); - text-decoration: none; - padding: 0.5rem 1rem; - border-radius: 4px; - transition: all 0.2s ease; - background: none; - border: none; - cursor: pointer; - font-size: 1rem; -} - -.nav-links a:hover, -.nav-links button:hover { - color: white; - background-color: rgba(255, 255, 255, 0.1); -} - -/* Typography */ -h1, h2, h3, h4, h5, h6 { - color: #2c3e50; - margin-bottom: 1rem; - font-weight: 600; -} - -h1 { font-size: 2.5rem; } -h2 { font-size: 2rem; } -h3 { font-size: 1.5rem; } - -/* Table Styles */ -table { - width: 100%; - border-collapse: collapse; - margin: 1.5rem 0; - background: white; - border-radius: 8px; - overflow: hidden; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -thead { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; -} - -th, td { - padding: 1rem; - text-align: left; - border-bottom: 1px solid #e9ecef; -} - -th { - font-weight: 600; - text-transform: uppercase; - font-size: 0.875rem; - letter-spacing: 0.5px; -} - -tbody tr:hover { - background-color: #f8f9fa; -} - -tbody tr:last-child td { - border-bottom: none; -} - -td a { - color: #667eea; - text-decoration: none; - margin-right: 0.5rem; -} - -td a:hover { - color: #5a67d8; - text-decoration: underline; -} - -/* Utility Classes */ -.row { - display: flex; - flex-wrap: wrap; - margin: 0 -0.75rem; -} - -.col-12 { - width: 100%; - padding: 0 0.75rem; -} - -.mb-4 { margin-bottom: 2rem; } -.mt-4 { margin-top: 2rem; } -.mt-2 { margin-top: 1rem; } -.mb-2 { margin-bottom: 1rem; } -.text-center { text-align: center; } - -/* Button Styles */ -.btn { - display: inline-block; - padding: 0.75rem 1.5rem; - border-radius: 6px; - text-decoration: none; - text-align: center; - border: none; - font-size: 1rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - margin: 0.25rem; -} - -.btn-primary { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.btn-primary:hover { - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); - color: white; - text-decoration: none; -} - -.btn-secondary { - background-color: #6c757d; - color: white; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.btn-secondary:hover { - background-color: #5a6268; - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); - color: white; - text-decoration: none; -} - -.btn-sm { - padding: 0.5rem 1rem; - font-size: 0.875rem; -} - -/* Form Styles */ -.form-group { - margin-bottom: 1.5rem; -} - -label { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - color: #495057; -} - -input[type="text"], -input[type="email"], -input[type="password"], -input[type="url"], -textarea, -select { - width: 100%; - padding: 0.75rem; - border: 2px solid #e9ecef; - border-radius: 6px; - font-size: 1rem; - transition: border-color 0.2s ease; - background-color: white; -} - -input[type="text"]:focus, -input[type="email"]:focus, -input[type="password"]:focus, -input[type="url"]:focus, -textarea:focus, -select:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); -} - -textarea { - min-height: 8rem; - resize: vertical; -} - -/* Flash Messages */ -.flash { - padding: 1rem; - border-radius: 6px; - margin: 1rem 0; - font-weight: 500; -} - -.flash.alert { - background-color: #f8d7da; - color: #721c24; - border: 1px solid #f5c6cb; -} - -.flash.notice { - background-color: #d4edda; - color: #155724; - border: 1px solid #c3e6cb; -} - -/* Action Links */ -.action-links { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -.action-links a { - padding: 0.375rem 0.75rem; - border-radius: 4px; - font-size: 0.875rem; - text-decoration: none; - transition: all 0.2s ease; -} - -.action-link-show { - background-color: #17a2b8; - color: white; -} - -.action-link-show:hover { - background-color: #138496; - color: white; -} - -.action-link-edit { - background-color: #ffc107; - color: #212529; -} - -.action-link-edit:hover { - background-color: #e0a800; - color: #212529; -} - -.action-link-destroy { - background-color: #dc3545; - color: white; -} - -.action-link-destroy:hover { - background-color: #c82333; - color: white; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .header-content { - flex-direction: column; - gap: 1rem; - text-align: center; - } - - .nav-links { - justify-content: center; - } - - .main-content { - margin: 1rem 0; - padding: 1.5rem; - } - - table { - font-size: 0.875rem; - } - - th, td { - padding: 0.5rem; - } - - .action-links { - flex-direction: column; - } -} - -@media (max-width: 480px) { - .container { - padding: 0 1rem; - } - - .main-content { - padding: 1rem; - } - - h1 { font-size: 2rem; } - h2 { font-size: 1.5rem; } -} - -/* Grid System */ -.col-md-4 { - width: 100%; -} - -@media (min-width: 768px) { - .col-md-4 { - width: 33.333333%; - } -} - -.mb-3 { margin-bottom: 1.5rem; } diff --git a/app/assets/stylesheets/bookmarklet.css b/app/assets/stylesheets/bookmarklet.css deleted file mode 100644 index c04d16d..0000000 --- a/app/assets/stylesheets/bookmarklet.css +++ /dev/null @@ -1,62 +0,0 @@ -/* Bookmarklet specific styles */ -.bookmarklet-container { - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); - border-radius: 12px; - padding: 2rem; - margin: 2rem 0; - text-align: center; - border: 1px solid #dee2e6; -} - -.bookmarklet-link { - display: inline-block; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white !important; - padding: 0.75rem 1.5rem; - border-radius: 6px; - text-decoration: none; - font-weight: 600; - font-size: 1.1rem; - box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); - transition: all 0.2s ease; -} - -.bookmarklet-link:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); - color: white !important; - text-decoration: none; -} - -.bookmarklet-instructions { - background-color: #f8f9fa; - border-left: 4px solid #667eea; - border-radius: 6px; - padding: 1.5rem; - margin: 2rem 0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.bookmarklet-instructions h3 { - color: #2c3e50; - margin-top: 0; - margin-bottom: 1rem; -} - -.bookmarklet-instructions ol { - padding-left: 1.5rem; - text-align: left; -} - -.bookmarklet-instructions li { - margin-bottom: 1rem; - line-height: 1.6; -} - -.bookmarklet-instructions code { - background-color: #e9ecef; - padding: 0.25rem 0.5rem; - border-radius: 3px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 0.9em; -} diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 0000000..2d72659 --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1,20 @@ +@import "tailwindcss"; + +/* Remove default link styling */ +a { + text-decoration: none; +} + +/* Base button reset */ +button { + font-family: inherit; + border: none; + background: transparent; + padding: 0; + margin: 0; +} + +/* Reset form button styling */ +form button, input[type="submit"] { + font-family: inherit; +} diff --git a/app/controllers/bookmarklet_controller.rb b/app/controllers/bookmarklet_controller.rb index 7ab5ccc..2f2bff5 100644 --- a/app/controllers/bookmarklet_controller.rb +++ b/app/controllers/bookmarklet_controller.rb @@ -4,14 +4,6 @@ class BookmarkletController < ApplicationController skip_before_action :verify_authenticity_token, only: [:create] before_action :authenticate_user!, unless: -> { Rails.env.test? || current_user } - def instructions - # Show bookmarklet installation instructions - end - - def debug - # Debugging page for Nostr extension integration - end - def add @bookmark = Bookmark.new( url: params[:url], diff --git a/app/controllers/debug_controller.rb b/app/controllers/debug_controller.rb new file mode 100644 index 0000000..65a52e2 --- /dev/null +++ b/app/controllers/debug_controller.rb @@ -0,0 +1,4 @@ +class DebugController < ApplicationController + def index + end +end diff --git a/app/controllers/instructions_controller.rb b/app/controllers/instructions_controller.rb new file mode 100644 index 0000000..809da46 --- /dev/null +++ b/app/controllers/instructions_controller.rb @@ -0,0 +1,4 @@ +class InstructionsController < ApplicationController + def index + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 70a73cd..53b1f2b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -28,6 +28,10 @@ def create def destroy reset_session - render json: { success: true, message: "Logged out successfully" } + + respond_to do |format| + format.html { redirect_to root_path, notice: "Logged out successfully" } + format.json { render json: { success: true, message: "Logged out successfully" } } + end end end \ No newline at end of file diff --git a/app/javascript/controllers/bookmarklet_add_controller.js b/app/javascript/controllers/bookmarklet_add_controller.js new file mode 100644 index 0000000..9cbd3e1 --- /dev/null +++ b/app/javascript/controllers/bookmarklet_add_controller.js @@ -0,0 +1,406 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["status", "form", "title", "url", "description", "submitButton", "formError"] + + connect() { + console.log("Bookmarklet add controller connected") + + // Initialize state + this.formSubmitting = false + + // Check if we're in a popup window + this.isPopup = new URLSearchParams(window.location.search).get('popup') === 'true' + console.log('đĒ Running in popup mode:', this.isPopup) + + // Initialize Nostr detection + this.initializeNostrDetection() + } + + initializeNostrDetection() { + // Global variables to track Nostr readiness + window.nostrReady = false + window.nostrExtensionFound = false + + let checkCount = 0 + const maxChecks = 10 // Try for 10 seconds + const checkInterval = 1000 // Check every second + + const checkForNostr = () => { + checkCount++ + console.log(`Nostr check attempt ${checkCount}/${maxChecks} in bookmarklet popup`) + + // Debug: Log what's available in window + console.log('window.nostr exists:', !!window.nostr) + + if (window.nostr) { + console.log('window.nostr type:', typeof window.nostr) + console.log('window.nostr object keys:', Object.keys(window.nostr)) + console.log('getPublicKey available:', typeof window.nostr.getPublicKey) + console.log('signEvent available:', typeof window.nostr.signEvent) + + const hasPubkey = typeof window.nostr.getPublicKey === 'function' + const hasSignEvent = typeof window.nostr.signEvent === 'function' + + if (hasPubkey && hasSignEvent) { + // Success! Nostr is ready + window.nostrReady = true + window.nostrExtensionFound = true + + this.statusTarget.textContent = "Nostr extension detected â" + this.statusTarget.className = "bg-green-50 px-4 py-3 rounded-lg border-l-4 border-green-500 mb-4 text-sm text-green-800 font-medium" + console.log('â Nostr extension fully detected and ready in bookmarklet popup') + + // Try to get pubkey to verify it's really working + window.nostr.getPublicKey() + .then(pubkey => { + this.statusTarget.textContent = `Nostr extension ready: ${pubkey.substring(0, 8)}...` + console.log('â Nostr pubkey retrieved successfully in popup:', pubkey) + }) + .catch(err => { + console.log('Note: Error getting pubkey (user may need to approve):', err.message) + // Don't change status - extension is still ready, user just needs to approve + }) + + return // Stop checking + } else { + let missingMethods = [] + if (!hasPubkey) missingMethods.push("getPublicKey") + if (!hasSignEvent) missingMethods.push("signEvent") + + this.statusTarget.textContent = `Nostr extension missing: ${missingMethods.join(", ")}` + this.statusTarget.className = "bg-red-50 px-4 py-3 rounded-lg border-l-4 border-red-500 mb-4 text-sm text-red-800 font-medium" + console.log('â Nostr extension found but missing methods:', missingMethods) + return // Stop checking + } + } else if (checkCount >= maxChecks) { + // We've tried enough times, give up + window.nostrReady = false + window.nostrExtensionFound = false + + this.statusTarget.textContent = "No Nostr extension detected. Bookmark will be saved without signing." + this.statusTarget.className = "bg-yellow-50 px-4 py-3 rounded-lg border-l-4 border-yellow-500 mb-4 text-sm text-yellow-800 font-medium" + console.log('â No Nostr extension found after', maxChecks, 'seconds in popup') + return + } else { + // Still checking + this.statusTarget.textContent = `Checking for Nostr extension... (${checkCount}/${maxChecks})` + console.log(`âŗ Nostr not found yet in popup, continuing to check... (${checkCount}/${maxChecks})`) + setTimeout(checkForNostr, checkInterval) + } + } + + // Start checking immediately + checkForNostr() + } + + async handleSubmit(event) { + console.log('đ¯ EMBEDDED JS: Form submit intercepted') + + // Prevent default form submission + event.preventDefault() + + // Prevent multiple submissions + if (this.formSubmitting) { + console.log('Form already submitting, ignoring duplicate') + return + } + + this.formSubmitting = true + + // Disable submit button + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.disabled = true + this.submitButtonTarget.textContent = 'Saving...' + } + + try { + // Get form data + const title = this.titleTarget.value + const url = this.urlTarget.value + const description = this.hasDescriptionTarget ? this.descriptionTarget.value : '' + + console.log('đ Form data:', { title, url, description }) + + // Update status + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Processing bookmark..." + this.statusTarget.className = "nostr-status" + } + + // Check Nostr readiness + console.log('đ Checking Nostr readiness...') + console.log(' window.nostrReady:', window.nostrReady) + console.log(' window.nostr exists:', !!window.nostr) + + // Try Nostr signing if available + let nostrSuccess = false + + if (window.nostrReady === true && window.nostr && + typeof window.nostr.getPublicKey === 'function' && + typeof window.nostr.signEvent === 'function') { + try { + console.log('đ Attempting Nostr signing...') + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Attempting Nostr signing..." + } + + nostrSuccess = await this.attemptNostrSigning(title, url, description) + console.log('đ¯ CRITICAL: Nostr signing result:', nostrSuccess) + } catch (error) { + console.error('â Error in Nostr signing:', error) + if (this.hasStatusTarget) { + this.statusTarget.textContent = `Nostr error: ${error.message}. Submitting without signing.` + this.statusTarget.className = "nostr-status nostr-status-error" + } + } + } else { + console.log('â Nostr not ready, submitting directly') + if (this.hasStatusTarget) { + this.statusTarget.textContent = "No Nostr extension ready. Submitting directly." + this.statusTarget.className = "nostr-status nostr-status-disconnected" + } + } + + // If Nostr signing didn't work, submit directly + if (!nostrSuccess) { + console.log('đ¤ Falling back to direct submission') + await this.submitFormDirectly(title, url, description) + } + } catch (error) { + console.error('â Unhandled error:', error) + + // Re-enable form + this.formSubmitting = false + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.disabled = false + this.submitButtonTarget.textContent = 'Save Bookmark' + } + + alert('An error occurred: ' + error.message) + } + } + + async attemptNostrSigning(title, url, description) { + console.log('đ Starting Nostr signing attempt') + + try { + // Step 1: Get public key + console.log('đ Step 1: Getting public key...') + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Requesting Nostr public key..." + } + + const pubkey = await window.nostr.getPublicKey() + console.log('â Got pubkey:', pubkey) + + // Step 2: Prepare event + console.log('đ Step 2: Preparing event...') + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Preparing Nostr event..." + } + + const event = this.prepareNostrEvent(title, url, description, pubkey) + console.log('â Prepared event:', event) + + // Step 3: Sign event + console.log('âī¸ Step 3: Requesting signature...') + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Requesting Nostr signature..." + } + + const signedEvent = await window.nostr.signEvent(event) + console.log('â Got signed event:', signedEvent) + + // Step 4: Validate + if (!signedEvent || !signedEvent.sig || !signedEvent.id) { + console.error('â Invalid signed event') + return false + } + + // Step 5: Submit to server + console.log('đ Step 5: Submitting to server...') + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Successfully signed! Submitting..." + this.statusTarget.className = "nostr-status nostr-status-connected" + } + + const result = await this.submitToServer(signedEvent, title, url, description, true) + return result + } catch (error) { + console.error('â Error in Nostr signing:', error) + throw error + } + } + + prepareNostrEvent(title, url, description, pubkey) { + // Extract d-tag from URL + const urlWithoutScheme = this.extractUrlDTag(url) + console.log('đˇī¸ Extracted d-tag:', urlWithoutScheme) + + const now = Math.floor(Date.now() / 1000) + + const tags = [ + ["d", urlWithoutScheme], + ["title", title], + ["published_at", now.toString()] + ] + + // Add hashtags + const hashtags = this.extractHashtags(description) + hashtags.forEach(tag => { + tags.push(["t", tag]) + }) + + return { + kind: 39701, + pubkey: pubkey, + created_at: now, + tags: tags, + content: description + } + } + + extractUrlDTag(url) { + try { + let fullUrl = url + if (!url.match(/^https?:\/\//i)) { + fullUrl = "https://" + url + } + + const urlObj = new URL(fullUrl) + return urlObj.hostname + urlObj.pathname + } catch (e) { + console.error("Error parsing URL:", e) + return url.replace(/^https?:\/\//i, '') + } + } + + extractHashtags(content) { + if (!content) return [] + + const hashtags = [] + const matches = content.match(/(?:\s|^)#([\w\d]+)/g) || [] + + matches.forEach(match => { + const tag = match.trim().substring(1) + if (tag && tag.length > 0) { + hashtags.push(tag) + } + }) + + return hashtags + } + + async submitToServer(signedEvent, title, url, description, nostrSigned = false) { + console.log('đ¤ Submitting to server with signed event') + console.log('đ¤ Signed event:', signedEvent) + + const csrfToken = document.querySelector('meta[name="csrf-token"]').content + + const payload = { + signed_event: signedEvent, + bookmark: { + title: title, + url: url, + description: description + }, + popup: this.isPopup ? true : undefined + } + + console.log('đ¤ Full payload:', JSON.stringify(payload, null, 2)) + + try { + const actionUrl = this.formTarget.getAttribute('action') + const response = await fetch(actionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify(payload) + }) + + console.log('đĨ Server response status:', response.status) + + if (!response.ok) { + const errorData = await response.json() + console.error('â Server error:', errorData) + throw new Error(errorData.errors ? errorData.errors.join(', ') : 'Server error') + } + + const data = await response.json() + console.log('â Server success:', data) + + // Redirect to the Rails success view + if (data.redirect_url) { + window.location.href = data.redirect_url + } + + return true + } catch (error) { + console.error('â Error submitting to server:', error) + alert('Failed to save bookmark: ' + error.message) + return false + } + } + + async submitFormDirectly(title, url, description) { + console.log('đ¤ Submitting form directly without Nostr') + + const csrfToken = document.querySelector('meta[name="csrf-token"]').content + + const payload = { + bookmark: { + title: title, + url: url, + description: description + }, + popup: this.isPopup ? true : undefined + } + + try { + const actionUrl = this.formTarget.getAttribute('action') + const response = await fetch(actionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify(payload) + }) + + console.log('đ¤ Direct submission response:', response) + + if (response.ok) { + const data = await response.json() + console.log('â Direct submission success:', data) + + // Redirect to the Rails success view + if (data.redirect_url) { + window.location.href = data.redirect_url + } + } else { + const errorData = await response.json() + throw new Error(errorData.errors ? errorData.errors.join(', ') : 'Server error') + } + } catch (error) { + console.error('â Error in direct submission:', error) + alert('Failed to submit form: ' + error.message) + + // Re-enable form + this.formSubmitting = false + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.disabled = false + this.submitButtonTarget.textContent = 'Save Bookmark' + } + } + } + + + cancelBookmark() { + window.close() + } +} \ No newline at end of file diff --git a/app/javascript/controllers/bookmarklet_debug_controller.js b/app/javascript/controllers/bookmarklet_debug_controller.js new file mode 100644 index 0000000..f3a6b3b --- /dev/null +++ b/app/javascript/controllers/bookmarklet_debug_controller.js @@ -0,0 +1,158 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["statusMessage", "detectionLog", "testResults", "testOutput", "userAgent", "currentUrl", "extensionsList"] + + connect() { + console.log("Bookmarklet debug controller connected") + + // Fill in browser info + this.userAgentTarget.textContent = navigator.userAgent + this.currentUrlTarget.textContent = window.location.href + + // Check for extensions + this.checkExtensions() + + // Start continuous Nostr checking + this.checkCount = 0 + this.continuousCheck() + } + + log(message) { + console.log(message) + const logEntry = document.createElement('div') + logEntry.className = 'log-entry' + logEntry.textContent = new Date().toLocaleTimeString() + ': ' + message + this.detectionLogTarget.appendChild(logEntry) + this.detectionLogTarget.scrollTop = this.detectionLogTarget.scrollHeight + } + + checkExtensions() { + const extensions = [] + + // Check for common extension objects + if (window.nostr) extensions.push('Nostr (window.nostr)') + if (window.webln) extensions.push('WebLN (window.webln)') + if (window.ethereum) extensions.push('Ethereum (window.ethereum)') + if (window.bitcoin) extensions.push('Bitcoin (window.bitcoin)') + + this.extensionsListTarget.textContent = extensions.length > 0 ? extensions.join(', ') : 'None detected' + } + + checkNostr() { + this.log('Starting Nostr check...') + + if (!window.nostr) { + this.statusMessageTarget.textContent = 'No window.nostr object found' + this.statusMessageTarget.className = 'status-error' + this.log('window.nostr is not defined') + return false + } + + this.log('window.nostr found: ' + typeof window.nostr) + this.log('window.nostr object: ' + JSON.stringify(Object.keys(window.nostr))) + + const hasPubkey = typeof window.nostr.getPublicKey === 'function' + const hasSignEvent = typeof window.nostr.signEvent === 'function' + + this.log('getPublicKey method: ' + typeof window.nostr.getPublicKey) + this.log('signEvent method: ' + typeof window.nostr.signEvent) + + if (hasPubkey && hasSignEvent) { + this.statusMessageTarget.textContent = 'Nostr extension detected and ready!' + this.statusMessageTarget.className = 'status-success' + this.log('Nostr extension fully functional') + return true + } else { + const missing = [] + if (!hasPubkey) missing.push('getPublicKey') + if (!hasSignEvent) missing.push('signEvent') + + this.statusMessageTarget.textContent = 'Nostr extension missing methods: ' + missing.join(', ') + this.statusMessageTarget.className = 'status-error' + this.log('Nostr extension missing methods: ' + missing.join(', ')) + return false + } + } + + continuousCheck() { + this.checkCount++ + this.log(`Continuous check #${this.checkCount}`) + + if (this.checkNostr()) { + this.log('Nostr detected, stopping continuous checks') + return + } + + if (this.checkCount < 20) { + setTimeout(() => this.continuousCheck(), 1000) + } else { + this.log('Gave up after 20 attempts') + } + } + + manualCheck() { + this.log('Manual check requested') + this.checkExtensions() + this.checkNostr() + } + + async testPubkey() { + this.testResultsTarget.style.display = 'block' + this.testOutputTarget.textContent = 'Testing getPublicKey...' + + if (!window.nostr || typeof window.nostr.getPublicKey !== 'function') { + this.testOutputTarget.textContent = 'Error: getPublicKey not available' + this.testOutputTarget.className = 'code-block error' + return + } + + try { + const pubkey = await window.nostr.getPublicKey() + this.testOutputTarget.textContent = 'Success: ' + pubkey + this.testOutputTarget.className = 'code-block success' + this.log('getPublicKey test successful: ' + pubkey) + } catch (error) { + this.testOutputTarget.textContent = 'Error: ' + error.message + this.testOutputTarget.className = 'code-block error' + this.log('getPublicKey test failed: ' + error.message) + } + } + + async testSigning() { + this.testResultsTarget.style.display = 'block' + this.testOutputTarget.textContent = 'Testing signEvent...' + + if (!window.nostr || typeof window.nostr.signEvent !== 'function' || typeof window.nostr.getPublicKey !== 'function') { + this.testOutputTarget.textContent = 'Error: Required Nostr methods not available' + this.testOutputTarget.className = 'code-block error' + return + } + + try { + // Get pubkey first + const pubkey = await window.nostr.getPublicKey() + + // Create a test event + const testEvent = { + kind: 1, + pubkey: pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'This is a test event from Pinstr debugging page.' + } + + // Sign it + const signedEvent = await window.nostr.signEvent(testEvent) + + // Display result + this.testOutputTarget.textContent = JSON.stringify(signedEvent, null, 2) + this.testOutputTarget.className = 'code-block success' + this.log('signEvent test successful') + } catch (error) { + this.testOutputTarget.textContent = 'Error: ' + error.message + this.testOutputTarget.className = 'code-block error' + this.log('signEvent test failed: ' + error.message) + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/bookmarklet_success_controller.js b/app/javascript/controllers/bookmarklet_success_controller.js new file mode 100644 index 0000000..144590c --- /dev/null +++ b/app/javascript/controllers/bookmarklet_success_controller.js @@ -0,0 +1,42 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["countdown"] + + connect() { + console.log("Bookmarklet success controller connected") + + // Check if we're in a popup window and start countdown + this.isPopup = new URLSearchParams(window.location.search).get('popup') === 'true' + + if (this.isPopup && this.hasCountdownTarget) { + this.startCountdown() + } + } + + startCountdown() { + let countdown = 3 + + const countdownInterval = setInterval(() => { + countdown-- + if (this.hasCountdownTarget) { + this.countdownTarget.textContent = countdown + } + + if (countdown <= 0) { + clearInterval(countdownInterval) + console.log('â° Auto-closing popup window') + this.closeWindow() + } + }, 1000) + } + + closeWindow() { + if (this.isPopup) { + window.close() + } else { + // If not in popup, redirect to bookmarks page + window.location.href = '/bookmarks' + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/flash_message_controller.js b/app/javascript/controllers/flash_message_controller.js new file mode 100644 index 0000000..9f14d56 --- /dev/null +++ b/app/javascript/controllers/flash_message_controller.js @@ -0,0 +1,30 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + // Check if user just logged in + if (sessionStorage.getItem('justLoggedIn') === 'true') { + sessionStorage.removeItem('justLoggedIn'); + this.showSuccessMessage('Successfully logged in with Nostr!'); + } + } + + showSuccessMessage(message) { + const flashContainer = document.createElement('div'); + flashContainer.className = 'flash notice'; + flashContainer.textContent = message; + + // Insert before main-content + const mainContent = document.querySelector('.main-content'); + if (mainContent && mainContent.parentNode) { + mainContent.parentNode.insertBefore(flashContainer, mainContent); + + // Auto-hide after 5 seconds + setTimeout(() => { + flashContainer.style.transition = 'opacity 0.5s'; + flashContainer.style.opacity = '0'; + setTimeout(() => flashContainer.remove(), 500); + }, 5000); + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/nostr_auth_controller.js b/app/javascript/controllers/nostr_auth_controller.js index 6e8c5fc..6d89a3d 100644 --- a/app/javascript/controllers/nostr_auth_controller.js +++ b/app/javascript/controllers/nostr_auth_controller.js @@ -1,18 +1,22 @@ import { Controller } from "@hotwired/stimulus" // Stimulus controller for Nostr authentication -// Manages detection of Nostr extension, requesting public key, and login/logout. +// Manages detection of Nostr extension, requesting public key, and login. export default class extends Controller { - static targets = ["loginButton", "status", "logoutButton"] + static targets = ["loginButton", "status"] connect() { console.log("Nostr auth controller connected"); - this.checkNostrExtension(); - // Check for extension every second in case it loads after the page - this.extensionCheckInterval = setInterval(() => { + // Only do status-related operations if we have a status target + if (this.hasStatusTarget) { this.checkNostrExtension(); - }, 1000); + + // Check for extension every second in case it loads after the page + this.extensionCheckInterval = setInterval(() => { + this.checkNostrExtension(); + }, 1000); + } } disconnect() { @@ -29,18 +33,28 @@ export default class extends Controller { if (window.nostr) { console.log("Nostr methods:", Object.keys(window.nostr)); - this.statusTarget.textContent = "Nostr extension detected â"; - this.statusTarget.classList.add("text-success"); - this.loginButtonTarget.disabled = false; + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Nostr extension detected â"; + this.statusTarget.classList.remove("text-danger"); + this.statusTarget.classList.add("text-success"); + } + if (this.hasLoginButtonTarget) { + this.loginButtonTarget.disabled = false; + } // Clear interval once extension is detected if (this.extensionCheckInterval) { clearInterval(this.extensionCheckInterval); } } else { - this.statusTarget.textContent = "No Nostr extension detected. Please install a Nostr extension."; - this.statusTarget.classList.add("text-danger"); - this.loginButtonTarget.disabled = true; + if (this.hasStatusTarget) { + this.statusTarget.textContent = "No Nostr extension detected. Please install a Nostr extension."; + this.statusTarget.classList.remove("text-success"); + this.statusTarget.classList.add("text-danger"); + } + if (this.hasLoginButtonTarget) { + this.loginButtonTarget.disabled = true; + } } } @@ -69,18 +83,28 @@ export default class extends Controller { console.log("Login button clicked"); // Disable button during login process - this.loginButtonTarget.disabled = true; - this.statusTarget.textContent = "Requesting public key..."; + if (this.hasLoginButtonTarget) { + this.loginButtonTarget.disabled = true; + } + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Requesting public key..."; + } try { const pubkey = await this.requestPublicKey(); if (!pubkey) { - this.loginButtonTarget.disabled = false; - this.statusTarget.textContent = "Failed to get public key"; + if (this.hasLoginButtonTarget) { + this.loginButtonTarget.disabled = false; + } + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Failed to get public key"; + } return; } - this.statusTarget.textContent = "Logging in..."; + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Logging in..."; + } // Send POST to /auth with the pubkey const response = await fetch("/auth", { @@ -95,45 +119,38 @@ export default class extends Controller { const responseData = await response.json().catch(() => null); if (response.ok) { - this.statusTarget.textContent = "Login successful! Redirecting..."; - window.location.reload(); + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Successfully logged in! Redirecting..."; + this.statusTarget.classList.remove("text-danger"); + this.statusTarget.classList.add("text-success"); + } + // Store a flag in sessionStorage to show success message after reload + sessionStorage.setItem('justLoggedIn', 'true'); + setTimeout(() => { + window.location.href = '/'; + }, 1000); } else { console.error("Login failed", response.status, responseData); - this.loginButtonTarget.disabled = false; - this.statusTarget.textContent = "Login failed: " + (responseData?.error || response.statusText); + if (this.hasLoginButtonTarget) { + this.loginButtonTarget.disabled = false; + } + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Login failed: " + (responseData?.error || response.statusText); + } alert("Login failed: " + (responseData?.error || response.statusText)); } } catch (error) { console.error("Login error:", error); - this.loginButtonTarget.disabled = false; - this.statusTarget.textContent = "Login error: " + error.message; + if (this.hasLoginButtonTarget) { + this.loginButtonTarget.disabled = false; + } + if (this.hasStatusTarget) { + this.statusTarget.textContent = "Login error: " + error.message; + } alert("Login error: " + error.message); } } - async logout() { - console.log("Logout button clicked"); - - try { - const response = await fetch("/auth", { - method: "DELETE", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": this.getMetaValue("csrf-token") - } - }); - - if (response.ok) { - window.location.reload(); - } else { - console.error("Logout failed", response); - alert("Logout failed"); - } - } catch (error) { - console.error("Logout error:", error); - alert("Logout error: " + error.message); - } - } getMetaValue(name) { const element = document.head.querySelector(`meta[name="${name}"]`); diff --git a/app/services/url_service.rb b/app/services/url_service.rb index 48df926..8bfa91e 100644 --- a/app/services/url_service.rb +++ b/app/services/url_service.rb @@ -1,4 +1,5 @@ require 'uri' +require 'active_model' class UrlService # Normalizes a URL to ensure consistent format @@ -73,22 +74,74 @@ def self.canonicalize(url, keep_params: false) canonical end - # Validates if a string is a valid URL + # Validates if a string is a valid URL using Rails' built-in validation def self.valid?(url) return false if url.blank? - # Try to normalize first - normalized = normalize(url) - - begin - uri = URI.parse(normalized) - # Check if it has a scheme and host - return false unless uri.scheme && uri.host && !uri.host.empty? - return true if uri.scheme =~ /\Ahttps?\z/i - false - rescue URI::InvalidURIError - false + # Create a temporary model to use Rails' URL validation + validator_class = Class.new do + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations + + attribute :url, :string + validates :url, format: { + with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), + message: 'is not a valid URL' + } + + # Additional custom validation + validate :validate_url_structure + + private + + def validate_url_structure + return if url.blank? + + begin + uri = URI.parse(url) + + # Must be HTTP or HTTPS + unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + errors.add(:url, 'must use http or https protocol') + return + end + + # Must have a valid host + unless uri.host&.present? + errors.add(:url, 'must have a valid host') + return + end + + host = uri.host.downcase.strip + + # Reject obviously invalid hosts + if %w[http https ftp].include?(host) + errors.add(:url, 'host cannot be a protocol name') + return + end + + if host.match?(/\A\.+\z/) + errors.add(:url, 'host cannot be only dots') + return + end + + # Require domain structure (except localhost) or valid IP + ip_pattern = /\A(\d{1,3}\.){3}\d{1,3}\z/ + unless host == 'localhost' || host.include?('.') || host.match?(ip_pattern) + errors.add(:url, 'host must be a valid domain or IP address') + end + + rescue URI::InvalidURIError + errors.add(:url, 'is malformed') + end + end end + + # Try to normalize first, then validate + normalized = normalize(url) + validator = validator_class.new(url: normalized) + validator.valid? end # Determines if two URLs are equivalent after canonicalization diff --git a/app/views/bookmarklet/add.html.erb b/app/views/bookmarklet/add.html.erb index cc98fbf..b4e45dc 100644 --- a/app/views/bookmarklet/add.html.erb +++ b/app/views/bookmarklet/add.html.erb @@ -1,17 +1,17 @@ -
- Note: If you have a Nostr extension installed (like nos2x or Alby), you'll be prompted to sign this bookmark. - Nostr extension not working? -
++ Note: If you have a Nostr extension installed, you'll be prompted to sign this bookmark. + Nostr extension not working? +
Checking...
- -| User Agent | -- |
|---|---|
| URL | -- |
| Extensions Detected | -- |
The Pinstr bookmarklet allows you to save webpages to your Pinstr account directly from your browser.
-- (Drag this link to your bookmarks bar) -
-The Pinstr bookmarklet works best with a Nostr browser extension installed:
-These extensions allow the bookmarklet to create a properly signed Nostr event (NIP-B0) for your bookmark, which can then be published to Nostr relays.
- -Your bookmark has been successfully saved to Pinstr.
+Your bookmark has been successfully saved to Pinstr.
<% if params[:nostr_signed] == 'true' %> -This window will close automatically in 3 seconds...
+
+
Want to use Nostr for your bookmarks?
- Set up Nostr integration
-
You can add hashtags using #tag format to categorize this bookmark.
| Title | -URL | -Description | -Actions | -
|---|
| <%= bookmark.title %> | -<%= link_to bookmark.url, bookmark.url, target: '_blank' %> | -<%= bookmark.description %> | -
-
- <%= link_to 'Show', bookmark, class: 'action-link-show' %>
- <%= link_to 'Edit', edit_bookmark_path(bookmark), class: 'action-link-edit' %>
- <%= link_to 'Delete', bookmark, method: :delete,
- data: { confirm: 'Are you sure you want to delete this bookmark?' },
- class: 'action-link-destroy' %>
-
- |
+ Title | +URL | +Description | +Actions |
|---|
No bookmarks yet. <%= link_to 'Create your first bookmark!', new_bookmark_path, class: 'btn btn-primary' %>
+No bookmarks yet. <%= link_to 'Create your first bookmark!', new_bookmark_path, class: 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white px-6 py-3 rounded-lg font-medium hover:from-indigo-700 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5' %>
Checking...
+ +| User Agent | ++ |
|---|---|
| URL | ++ |
| Extensions Detected | ++ |
Pinstr is a bookmark manager that uses Nostr to store and sync your bookmarks.
+Pinstr is a bookmark manager that uses Nostr to store and sync your bookmarks.
<% if current_user %> -You are logged in with public key <%= current_user.public_key.truncate(10) %>.
- <%= link_to "View your bookmarks", bookmarks_path, class: "btn btn-primary" %> +You are logged in with public key <%= current_user.public_key.truncate(10) %>.
+ <%= link_to "View your bookmarks", bookmarks_path, class: "bg-gradient-to-r from-indigo-600 to-purple-600 text-white px-6 py-3 rounded-lg font-medium hover:from-indigo-700 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" %>Please log in with Nostr to manage your bookmarks.
- <%= link_to "Login", new_session_path, class: "btn btn-primary" %> +Please log in with Nostr to manage your bookmarks.
+ <%= link_to "Login", new_session_path, class: "bg-gradient-to-r from-indigo-600 to-purple-600 text-white px-6 py-3 rounded-lg font-medium hover:from-indigo-700 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" %>Drag this bookmarklet to your bookmarks bar to quickly save pages to Pinstr:
+Drag this bookmarklet to your bookmarks bar to quickly save pages to Pinstr:
-- (Drag this link to your bookmarks bar) +
+ (Drag this link to your bookmarks bar)
-
+ For full functionality, you'll need a Nostr browser extension. We recommend: For full functionality, you'll need a Nostr browser extension. The Pinstr bookmarklet allows you to save webpages to your Pinstr account directly from your browser.
+ (Drag this link to your bookmarks bar)
+ The Pinstr bookmarklet works best with a Nostr browser extension installed: These extensions allow the bookmarklet to create a properly signed Nostr event (NIP-B0) for your bookmark, which can then be published to Nostr relays.Nostr Integration
-Nostr Integration
+Pinstr Bookmarklet
+ Installation
+ How to Install:
+
+
+ How to Use
+
+
+ Nostr Integration
+
+
+
+ Troubleshooting
+ Common Issues:
+
+
+ Pinstr
-