From e4f154ca5c2eee1d3a68239771c448396da477b2 Mon Sep 17 00:00:00 2001 From: Dean Lofts Date: Sat, 24 Aug 2024 13:23:18 +1000 Subject: [PATCH 1/9] remove old test dir --- test/application_system_test_case.rb | 5 ----- .../channels/application_cable/connection_test.rb | 13 ------------- test/controllers/.keep | 0 test/controllers/links_controller_test.rb | 7 ------- test/fixtures/files/.keep | 0 test/fixtures/links.yml | 11 ----------- test/fixtures/users.yml | 11 ----------- test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/models/link_test.rb | 7 ------- test/models/user_test.rb | 7 ------- test/system/.keep | 0 test/test_helper.rb | 15 --------------- 15 files changed, 76 deletions(-) delete mode 100644 test/application_system_test_case.rb delete mode 100644 test/channels/application_cable/connection_test.rb delete mode 100644 test/controllers/.keep delete mode 100644 test/controllers/links_controller_test.rb delete mode 100644 test/fixtures/files/.keep delete mode 100644 test/fixtures/links.yml delete mode 100644 test/fixtures/users.yml delete mode 100644 test/helpers/.keep delete mode 100644 test/integration/.keep delete mode 100644 test/mailers/.keep delete mode 100644 test/models/.keep delete mode 100644 test/models/link_test.rb delete mode 100644 test/models/user_test.rb delete mode 100644 test/system/.keep delete mode 100644 test/test_helper.rb diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb deleted file mode 100644 index d19212a..0000000 --- a/test/application_system_test_case.rb +++ /dev/null @@ -1,5 +0,0 @@ -require "test_helper" - -class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - driven_by :selenium, using: :chrome, screen_size: [1400, 1400] -end diff --git a/test/channels/application_cable/connection_test.rb b/test/channels/application_cable/connection_test.rb deleted file mode 100644 index 6340bf9..0000000 --- a/test/channels/application_cable/connection_test.rb +++ /dev/null @@ -1,13 +0,0 @@ -require "test_helper" - -module ApplicationCable - class ConnectionTest < ActionCable::Connection::TestCase - # test "connects with cookies" do - # cookies.signed[:user_id] = 42 - # - # connect - # - # assert_equal connection.user_id, "42" - # end - end -end diff --git a/test/controllers/.keep b/test/controllers/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/controllers/links_controller_test.rb b/test/controllers/links_controller_test.rb deleted file mode 100644 index 159a290..0000000 --- a/test/controllers/links_controller_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class LinksControllerTest < ActionDispatch::IntegrationTest - # test "the truth" do - # assert true - # end -end diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/fixtures/links.yml b/test/fixtures/links.yml deleted file mode 100644 index 79bbba3..0000000 --- a/test/fixtures/links.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - -one: - title: MyString - url: MyString - user: one - -two: - title: MyString - url: MyString - user: two diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml deleted file mode 100644 index d7a3329..0000000 --- a/test/fixtures/users.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html - -# This model initially had no columns defined. If you add columns to the -# model remove the "{}" from the fixture names and add the columns immediately -# below each fixture, per the syntax in the comments below -# -one: {} -# column: value -# -two: {} -# column: value diff --git a/test/helpers/.keep b/test/helpers/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/integration/.keep b/test/integration/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/mailers/.keep b/test/mailers/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/models/.keep b/test/models/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/models/link_test.rb b/test/models/link_test.rb deleted file mode 100644 index c4a0f6e..0000000 --- a/test/models/link_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class LinkTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/user_test.rb b/test/models/user_test.rb deleted file mode 100644 index 5c07f49..0000000 --- a/test/models/user_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class UserTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/system/.keep b/test/system/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index 0c22470..0000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -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 From 01a3de2ddae25adfc4c1df1dae01d807e6b24687 Mon Sep 17 00:00:00 2001 From: Dean Lofts Date: Sat, 24 Aug 2024 13:27:37 +1000 Subject: [PATCH 2/9] add example env --- env.example | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 env.example diff --git a/env.example b/env.example new file mode 100644 index 0000000..53c430f --- /dev/null +++ b/env.example @@ -0,0 +1,8 @@ +SECRET_KEY_BASE= +AXIOM_API_KEY= +GEOCODER_API_KEY= +DO_TOKEN= +SPACES_ACCESS_KEY_ID= +SPACES_SECRET_ACCESS_KEY= +SPACES_REGION= +SPACES_BUCKET_NAME= \ No newline at end of file From e53f364a215dbba6f2f381e75d20f326c0b51971 Mon Sep 17 00:00:00 2001 From: Dean Lofts Date: Sat, 24 Aug 2024 13:27:46 +1000 Subject: [PATCH 3/9] remove old docker compose --- docker-compose.yml | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 8183217..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,35 +0,0 @@ -services: - app: - image: ghcr.io/loftwah/linkarooie:latest - ports: - - "80:3000" - env_file: - - .env - environment: - - RAILS_ENV=production - - REDIS_URL=redis://redis:6379/0 - volumes: - - ./storage:/rails/storage - depends_on: - - redis - user: "1000:1000" # This assumes the rails user has UID and GID 1000 - - redis: - image: redis:6-alpine - volumes: - - redis_data:/data - - sidekiq: - image: ghcr.io/loftwah/linkarooie:latest - command: bundle exec sidekiq - environment: - - RAILS_ENV=production - - REDIS_URL=redis://redis:6379/0 - volumes: - - ./storage:/rails/storage - depends_on: - - redis - user: "1000:1000" # This assumes the rails user has UID and GID 1000 - -volumes: - redis_data: \ No newline at end of file From 8ab6e6cab44a44d8d2b8c06ca0eb4583de51a77f Mon Sep 17 00:00:00 2001 From: Dean Lofts Date: Sat, 24 Aug 2024 13:39:14 +1000 Subject: [PATCH 4/9] gather script v1 --- README.md | 11 ++++ gather.sh | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100755 gather.sh diff --git a/README.md b/README.md index 3ba2e0c..1f85cef 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,17 @@ In the event that you need to restore your database from a backup, you can use t Currently, geolocation functionality is mandatory, but I plan to make it optional in future updates. To enable geolocation, you will need an [API key from ipapi](https://ipapi.com), which is free to obtain. +## Gather.sh + +I wrote a script to gather information that is useful to copy paste into things like ChatGPT or Cluade. + +```bash +➜ linkarooie git:(dl/public-analytics-page) ✗ ./gather.sh --help +Usage: ./gather.sh [-o output_method] [-f output_file] + -o, --output Output method: stdout, clipboard, or file (default: stdout) + -f, --file Output file path (required if output method is file) + ``` + ## Customization Linkarooie is highly customizable: diff --git a/gather.sh b/gather.sh new file mode 100755 index 0000000..34a0809 --- /dev/null +++ b/gather.sh @@ -0,0 +1,165 @@ +#!/bin/bash + +# Default output method +output_method="stdout" +output_content="" + +# Detect the operating system +os_type="$(uname -s)" + +# Determine the clipboard command based on the OS +if [[ "$os_type" == "Darwin" ]]; then + clipboard_command="pbcopy" +elif [[ "$os_type" == "Linux" ]]; then + clipboard_command="xclip -selection clipboard" + # Check if xclip is installed + if ! command -v xclip &> /dev/null; then + echo "xclip is not installed. Please install it using: sudo apt install xclip" + exit 1 + fi +else + echo "Unsupported OS type: $os_type" + exit 1 +fi + +# Usage message +usage() { + echo "Usage: $0 [-o output_method] [-f output_file]" + echo " -o, --output Output method: stdout, clipboard, or file (default: stdout)" + echo " -f, --file Output file path (required if output method is file)" + exit 1 +} + +# Parse command-line arguments +while [[ "$1" != "" ]]; do + case $1 in + -o | --output ) shift + output_method=$1 + ;; + -f | --file ) shift + output_file=$1 + ;; + -h | --help ) usage + exit + ;; + * ) usage + exit 1 + esac + shift +done + +# Function to output content based on selected output method +output() { + local content="$1" + output_content+="$content\n" +} + +# Function to finalize output +finalize_output() { + case $output_method in + "stdout") echo -e "$output_content" ;; + "clipboard") echo -e "$output_content" | $clipboard_command ;; # Use appropriate clipboard command + "file") echo -e "$output_content" > "$output_file" ;; + *) echo "Invalid output method"; exit 1 ;; + esac +} + +# Check if file output is specified but no file is given +if [[ $output_method == "file" && -z $output_file ]]; then + echo "Error: Output file must be specified when using file output method." + usage +fi + +# Start the script output +output "=== Directory Structure and File Contents ===\n" + +# Functions for tasks +show_tree() { + local dir="$1" + output "Tree for $dir:\n" + output "$(tree "$dir")\n" +} + +show_file_content() { + local file="$1" + output "Contents of $file:\n" + output "$(cat "$file")\n" +} + +# Main tasks +show_tree "app" +show_tree "db" + +output "=== Contents of Ruby Files in db/ ===\n" +for file in db/*.rb; do + output "File: $file\n" + output "$(cat "$file")\n" +done + +show_tree "config" + +output "=== Config Files ===\n" +show_file_content "config/database.yml" + +output "=== Contents of Ruby Files in config/environments/ ===\n" +for file in config/environments/*.rb; do + output "File: $file\n" + output "$(cat "$file")\n" +done + +show_file_content "config/routes.rb" +show_file_content "config/tailwind.config.js" + +show_tree "terraform" + +output "=== Terraform Files ===\n" +find ./terraform -type f -name "*.tf" -exec sh -c 'echo "Processing file: $1"; cat "$1"' _ {} \; | while read line; do output "$line\n"; done + +output "=== App Directory Files (Ruby and HTML ERB) ===\n" +find ./app -type f \( -name "*.rb" -o -name "*.html.erb" \) -exec sh -c 'echo "Processing file: $1"; cat "$1"' _ {} \; | while read line; do output "$line\n"; done + +output "=== GitHub Workflow Files ===\n" +for file in .github/workflows/*.yml; do + output "File: $file\n" + output "$(cat "$file")\n" +done + +show_tree "bin" + +output "=== Spec Directory Files (Ruby) ===\n" +find spec -type f -name "*.rb" -exec sh -c 'for file; do echo "$file"; cat "$file"; done' _ {} + | while read line; do output "$line\n"; done + +output "=== Public Directory (One Level Deep) ===\n" +output "$(tree -L 1 public)\n" + +output "=== Rake Files in lib/tasks/ ===\n" +for file in lib/tasks/*.rake; do + output "File: $file\n" + output "$(cat "$file")\n" +done + +output "=== Other Configurations and Files ===\n" +show_file_content "Procfile.dev" +show_file_content "Gemfile" +show_file_content "docker-compose.prod.yml" +show_file_content "Dockerfile" +show_file_content ".ruby-version" +show_file_content "package.json" + +output "=== Vite Configuration Files ===\n" +for file in vite.config.*; do + output "File: $file\n" + output "$(cat "$file")\n" +done + +show_file_content ".gitignore" +show_file_content ".dockerignore" + +output "=== Generated env.example from .env ===\n" +output "$(sed 's/=.*/=/' .env)\n" + +# End of script +output "=== End of Output ===\n" + +# Finalize output +finalize_output From 7488e197b4627a494d9960fa0d1e7456f5927e54 Mon Sep 17 00:00:00 2001 From: Dean Lofts Date: Sat, 24 Aug 2024 13:41:09 +1000 Subject: [PATCH 5/9] finish gather script --- gather.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gather.sh b/gather.sh index 34a0809..894ea45 100755 --- a/gather.sh +++ b/gather.sh @@ -113,10 +113,10 @@ show_file_content "config/tailwind.config.js" show_tree "terraform" output "=== Terraform Files ===\n" -find ./terraform -type f -name "*.tf" -exec sh -c 'echo "Processing file: $1"; cat "$1"' _ {} \; | while read line; do output "$line\n"; done +find ./terraform -type f -name "*.tf" -exec sh -c 'echo "Processing file: $1"; cat "$1"' _ {} \; | while read -r line; do output "$line\n"; done output "=== App Directory Files (Ruby and HTML ERB) ===\n" -find ./app -type f \( -name "*.rb" -o -name "*.html.erb" \) -exec sh -c 'echo "Processing file: $1"; cat "$1"' _ {} \; | while read line; do output "$line\n"; done +find ./app -type f \( -name "*.rb" -o -name "*.html.erb" \) -exec sh -c 'echo "Processing file: $1"; cat "$1"' _ {} \; | while read -r line; do output "$line\n"; done output "=== GitHub Workflow Files ===\n" for file in .github/workflows/*.yml; do @@ -127,7 +127,7 @@ done show_tree "bin" output "=== Spec Directory Files (Ruby) ===\n" -find spec -type f -name "*.rb" -exec sh -c 'for file; do echo "$file"; cat "$file"; done' _ {} + | while read line; do output "$line\n"; done +find spec -type f -name "*.rb" -exec sh -c 'for file; do echo "$file"; cat "$file"; done' _ {} + | while read -r line; do output "$line\n"; done output "=== Public Directory (One Level Deep) ===\n" output "$(tree -L 1 public)\n" From 6f06d0aa80b863ecf5ff5b8f98f3f8ab2cb25bda Mon Sep 17 00:00:00 2001 From: Dean Lofts Date: Sat, 24 Aug 2024 13:49:15 +1000 Subject: [PATCH 6/9] improve readme --- README.md | 456 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 317 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 1f85cef..b7310c0 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,104 @@ # Linkarooie -[![Build Status](https://github.com/loftwah/linkarooie/actions/workflows/ci.yml/badge.svg)](https://github.com/loftwah/linkarooie/actions) - -Linkarooie is a simple and open-source alternative to Linktree, allowing you to manage and share all your important links in one place. This project was created as a replacement for BioDrop (LinkFree) after it was archived. It is built using Ruby on Rails and is designed to be easy to deploy and customize. +[![Build Status](https://github.com/loftwah/linkarooie/actions/workflows/ci.yml/badge.svg)](https://github.com/loftwah/linkarooie/actions) [![Docker Image Available](https://img.shields.io/badge/Docker%20image-available-blue?logo=docker)](https://github.com/users/loftwah/packages/container/package/linkarooie) + +Linkarooie is a robust, open-source alternative to Linktree, built with Ruby on Rails. It provides a centralized platform for managing and sharing your important links, achievements, and online presence. Created as a replacement for the archived BioDrop (LinkFree) project, Linkarooie offers a feature-rich, customizable solution designed for easy deployment and management. + +## Table of Contents + +1. [Features](#features) +2. [Tech Stack](#tech-stack) +3. [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Local Development Setup](#local-development-setup) + - [Creating a New User](#creating-a-new-user) +4. [Docker Deployment](#docker-deployment) +5. [DigitalOcean Deployment](#digitalocean-deployment) +6. [Configuration](#configuration) + - [Environment Variables](#environment-variables) + - [Database Configuration](#database-configuration) +7. [Backup and Restore Process](#backup-and-restore-process) +8. [Geolocation](#geolocation) +9. [Customization](#customization) +10. [Testing](#testing) +11. [CI/CD](#cicd) +12. [Project Structure](#project-structure) +13. [Key Components](#key-components) +14. [Gather Script](#gather-script) +15. [Contributing](#contributing) +16. [License](#license) +17. [Support](#support) +18. [Acknowledgements](#acknowledgements) ## Features -- **Custom Links:** Easily add and manage your links. -- **Analytics:** Track the performance of your links. -- **Social Integration:** Seamlessly connect all your social media profiles. -- **Responsive Design:** Works well on any device. -- **User Achievements:** Showcase your accomplishments. -- **Pinned Links:** Highlight your most important links. -- **User Profiles:** Customizable profiles with avatars, banners, and descriptions. -- **Open Graph Image Generation:** Automatically generate social media preview images. - -## Demo - -Check out a live demo at [linkarooie.com](https://linkarooie.com). +- **Custom Links Management:** + - Add, edit, and delete links with titles, URLs, descriptions, and custom icons + - Toggle link visibility + - Pin important links to the top of your profile + - Organize links with custom positioning + +- **User Profiles:** + - Customizable profiles with avatars, banners, full names, usernames, and descriptions + - Tagging system for categorizing profiles + +- **User Achievements:** + - Create and showcase personal or professional accomplishments + - Include achievement titles, dates, descriptions, icons, and URLs + +- **Analytics:** + - Track page views, link clicks, and unique visitors + - View daily metrics for user engagement + - Geolocation tracking for visitor insights (currently mandatory) + +- **Open Graph Image Generation:** + - Automatic creation of social media preview images for improved sharing + +- **Responsive Design:** + - Ensures optimal user experience across all devices + +- **Background Job Processing:** + - Utilizes Sidekiq for efficient handling of background tasks + +- **Asset Management:** + - Implements Vite for modern, efficient frontend asset handling + +- **Automated Backups:** + - Daily backups to DigitalOcean Spaces with easy restoration process + +## Tech Stack + +- **Backend:** + - Ruby 3.3.0 (local development) + - Ruby 3.3.4 (production Docker image) + - Rails 7.1.3 + - SQLite3 (development) + - PostgreSQL/MySQL (recommended for production) + - Sidekiq for background job processing + - Redis for Sidekiq and caching + +- **Frontend:** + - Vite for asset compilation and management + - Tailwind CSS for styling + - Stimulus.js for JavaScript sprinkles + - Chartkick for chart generation + +- **Testing:** + - RSpec for unit and integration tests + - Factory Bot for test data generation + - Shoulda Matchers for additional RSpec matchers + +- **Deployment & Infrastructure:** + - Docker and Docker Compose for containerization + - GitHub Actions for CI/CD + - Terraform for infrastructure as code + - DigitalOcean for hosting (Droplets and Spaces) + +- **Additional Libraries:** + - Devise for authentication + - Geocoder for geolocation services + - AWS SDK for S3 compatible storage interactions + - Font Awesome for icons ## Getting Started @@ -26,219 +107,310 @@ Check out a live demo at [linkarooie.com](https://linkarooie.com). - Ruby 3.3.0 or higher - Rails 7.1.3 or higher - SQLite3 -- Node.js and npm (for asset compilation) +- Node.js (v20 or higher) and npm - Docker and Docker Compose (for containerized deployment) - Git ### Local Development Setup 1. Clone the repository: - ```bash git clone https://github.com/loftwah/linkarooie.git cd linkarooie ``` -2. Install dependencies: - +2. Install Ruby dependencies: ```bash bundle install - npm install ``` -3. Set up the database: - +3. Install JavaScript dependencies: ```bash - rails db:create db:migrate + npm install ``` -4. Start the Rails server: +4. Set up the database: + ```bash + rails db:create db:migrate db:seed + ``` +5. Start the development servers: ```bash bin/dev ``` + This command starts the Rails server, Vite dev server, and Tailwind CSS watcher. -5. Visit `http://localhost:3000` in your browser. +6. Visit `http://localhost:3000` in your browser to access the application. ### Creating a New User -This section explains how to create a new user using an interactive Ruby script. This method allows you to quickly add users to your Rails application with the required and optional attributes. +Linkarooie provides an interactive Ruby script for creating new users: -#### Using the Interactive Ruby Script +1. Run the script: + ```bash + ruby create_user.rb + ``` -The Ruby script offers a user-friendly and interactive way to create a new user by prompting you for the required and optional details. +2. Follow the prompts to enter user details, including: + - Email + - Password + - Username (optional) + - Full name + - Tags (comma-separated) + - Avatar URL + - Banner URL + - Description -##### Usage +This script allows for easy user creation, especially useful for setting up initial accounts or testing. -Run the script by navigating to your Rails project directory and executing the following command: +## Docker Deployment -```bash -ruby create_user.rb -``` +Linkarooie uses Docker for easy deployment and scaling. The project includes a multi-stage Dockerfile for creating a lean production image. -You will be prompted to enter various details for the new user: +1. Build and start the Docker containers: + ```bash + docker compose -f docker-compose.prod.yml up --build + ``` -```bash -Enter email: bob.johnson@example.com -Enter password: AnotherSuperSecretPassword -Enter username (or leave blank to use email prefix): bob_j -Enter full name: Bob Johnson -Enter tags (comma-separated): Marketing,SEO,Content Strategy -Enter avatar URL: https://pbs.twimg.com/profile_images/1756873036220059648/zc13kjbX_400x400.jpg -Enter banner URL: https://pbs.twimg.com/profile_banners/1192091185/1719830949/1500x500 -Enter description: Digital marketing expert focused on driving growth through innovative SEO and content strategies. -``` +2. Access the application at `http://localhost`. -##### Example +The production Docker setup includes: +- Rails application container +- Redis container for Sidekiq and caching +- Sidekiq container for background job processing -The details entered above will create a user with the following attributes: +Key Dockerfile features: +- Multi-stage build for a smaller final image +- Precompilation of assets and bootsnap +- Non-root user for improved security -* **Email:** `bob.johnson@example.com` -* **Password:** `AnotherSuperSecretPassword` -* **Username:** `bob_j` -* **Full Name:** `Bob Johnson` -* **Tags:** `Marketing`, `SEO`, `Content Strategy` -* **Avatar:** Same as provided -* **Banner:** Same as provided -* **Description:** "Digital marketing expert focused on driving growth through innovative SEO and content strategies." +## DigitalOcean Deployment -After entering all the details, the script will create the user and confirm whether the process was successful. +Linkarooie is optimized for deployment on DigitalOcean using Terraform for infrastructure management and GitHub Actions for continuous deployment. -### Docker Deployment +### Setting up DigitalOcean Infrastructure -1. Build and start the Docker containers: +1. Install Terraform and set up a DigitalOcean account. +2. Configure your DigitalOcean API token: ```bash - docker compose up --build + export DO_TOKEN=your_digitalocean_api_token ``` -2. Visit `http://localhost` in your browser. - -Note: The Dockerfile uses a multi-stage build process to create a lean production image. It installs necessary dependencies, precompiles assets, and sets up the application to run as a non-root user for improved security. - -### DigitalOcean Deployment - -1. Set up a DigitalOcean account and generate an API token. - -2. Create a DigitalOcean Droplet with Terraform: - +3. Create a DigitalOcean Droplet: ```bash + cd terraform/droplet terraform init - terraform apply -var="do_token=YOUR_DIGITALOCEAN_TOKEN" + terraform apply -var="do_token=$DO_TOKEN" ``` -3. Deploy the app using GitHub Actions: - - * Ensure you have the following secrets set in your GitHub repository: - * `DROPLET_IP`: The IP address of your DigitalOcean Droplet (output from Terraform). - * `DROPLET_SSH_PRIVATE_KEY`: The private SSH key to access your Droplet. - * `GH_PAT`: Your GitHub Personal Access Token. +4. Set up DigitalOcean Spaces for backups: + ```bash + cd ../spaces + terraform init + terraform apply + ``` - * Push your code to the `main` branch, and the GitHub Actions workflow will automatically deploy the latest version to your Droplet. +### Configuring GitHub Actions -4. Important Note on Docker Installation: - The user data script in the Terraform configuration installs Docker using the get.docker.com script. However, this installation is not instantaneous and may take a few minutes to complete after the Droplet is created. +1. Set up the following secrets in your GitHub repository: + - `DROPLET_IP`: The IP address of your DigitalOcean Droplet (output from Terraform) + - `DROPLET_SSH_PRIVATE_KEY`: The private SSH key to access your Droplet + - `GH_PAT`: Your GitHub Personal Access Token -5. Checking Docker Installation Status: - If you encounter issues with Docker not being available immediately after Droplet creation, you can check the cloud-init logs: +2. The GitHub Actions workflows will automatically: + - Run tests and build Docker images on pull requests and pushes to feature branches + - Deploy to your DigitalOcean Droplet when changes are merged to the main branch - ```bash - ssh root@YOUR_DROPLET_IP - sudo tail -f /var/log/cloud-init-output.log - ``` +### Manual Deployment - This will show you the progress of the initialization script, including the Docker installation. +You can also trigger a manual deployment using the GitHub Actions workflow dispatch event. ## Configuration ### Environment Variables -- `RAILS_ENV`: Set to `production` for production environments. -- `SECRET_KEY_BASE`: Rails secret key for production. -- `DATABASE_URL`: Database connection string (if using a different database in production). -- `CACHE_EXPIRATION`: Duration in minutes for caching analytics data (default: 30 minutes). +Create a `.env` file in the root directory with the following variables: -### Database +``` +SECRET_KEY_BASE=your_secret_key_base +AXIOM_API_KEY=your_axiom_api_key +GEOCODER_API_KEY=your_geocoder_api_key +DO_TOKEN=your_digitalocean_token +SPACES_ACCESS_KEY_ID=your_spaces_access_key_id +SPACES_SECRET_ACCESS_KEY=your_spaces_secret_access_key +SPACES_REGION=your_spaces_region +SPACES_BUCKET_NAME=your_spaces_bucket_name +RAILS_ENV=production +CACHE_EXPIRATION=30 +``` -The project uses SQLite by default. For production, consider using PostgreSQL or MySQL. +Ensure all placeholder values are replaced with your actual API keys and tokens. -### Backup and Restore Process +### Database Configuration -Linkarooie includes an automated backup system to ensure that your SQLite database is securely stored and easily recoverable. This process is managed using a combination of scheduled jobs and DigitalOcean Spaces for storage. +- Development and test environments use SQLite3. +- For production, configure your preferred database (PostgreSQL recommended) in `config/database.yml`. -#### Automated Backups +## Backup and Restore Process -The `BackupDatabaseJob` is scheduled to run daily at 2 AM, ensuring that your SQLite database is backed up regularly. The backup process involves the following steps: +Linkarooie includes an automated backup system utilizing DigitalOcean Spaces: -1. **Database Dump**: The job creates a dump of the current SQLite database, storing it in the `db/backups` directory with a timestamp and environment identifier. -2. **Upload to DigitalOcean Spaces**: The backup file is then uploaded to a DigitalOcean Spaces bucket, where it is securely stored with versioning enabled. This ensures that previous versions of the backup are retained for a short period, allowing you to restore from a specific point in time if needed. -3. **Cleanup**: Optionally, the local backup file is deleted after it has been successfully uploaded to DigitalOcean Spaces. +### Automated Backups -#### Restoring from a Backup +- The `BackupDatabaseJob` runs daily at 2 AM. +- It creates a dump of the SQLite database and uploads it to a DigitalOcean Spaces bucket. +- Backups are versioned for easy point-in-time recovery. -In the event that you need to restore your database from a backup, you can use the provided Rake task. This task allows you to specify the backup file you want to restore from and automatically loads it into the SQLite database. +### Restoring from a Backup -**Restoration Steps:** +Use the provided Rake task to restore from a backup: + +```bash +rake db:restore BACKUP_FILE=path/to/your_backup_file.sql +``` -1. **Run the Restore Task**: Use the following command, specifying the path to your backup file: +For compressed backups: - ```bash - rake db:restore BACKUP_FILE=path/to/your_backup_file.sql - ``` +```bash +rake db:restore BACKUP_FILE=path/to/your_backup_file.sql.tar.gz +``` + +The restore process: +1. Drops all existing tables in the database. +2. Loads the specified backup file. +3. Applies any pending migrations. -2. **Process Overview**: +## Geolocation - * The task will first drop all existing tables in the database to ensure a clean restoration. - * It will then load the specified backup file into the database. - * Upon completion, your database will be restored to the state it was in when the backup was created. +Geolocation is currently a mandatory feature in Linkarooie. It uses the `geocoder` gem to provide location-based insights for link clicks and page views. -3. **Error Handling**: If the backup file is not provided or if any errors occur during the restoration process, the task will output helpful messages to guide you in resolving the issue. +To enable geolocation: +1. Obtain a free API key from [ipapi](https://ipapi.com). +2. Set the `GEOCODER_API_KEY` environment variable with your API key. -#### Important Notes +Future plans include making geolocation optional to cater to different privacy preferences. -* **Environment-Specific Backups**: Backups are created separately for each environment (development, production, test), and the backup files are named accordingly. -* **DigitalOcean Spaces Configuration**: Ensure that your DigitalOcean API credentials and bucket details are correctly configured in your environment variables for the backup and restore processes to function properly. -* **Testing Restores**: Regularly test the restore process in a development environment to ensure that your backups are reliable and that the restore process works as expected. +## Customization -### Geolocation +Linkarooie is designed to be highly customizable: + +- **Views:** Modify ERB templates in `app/views/` +- **Styles:** + - Edit Tailwind CSS classes directly in views + - Customize Tailwind configuration in `config/tailwind.config.js` + - Add custom styles in `app/assets/stylesheets/application.css.scss` +- **JavaScript:** + - Add or modify Stimulus controllers in `app/javascript/controllers/` + - Update the main JavaScript file at `app/javascript/application.js` +- **Backend Logic:** + - Controllers are located in `app/controllers/` + - Models are in `app/models/` +- **Background Jobs:** Add or modify Sidekiq jobs in `app/jobs/` +- **Localization:** Update language files in `config/locales/` -Currently, geolocation functionality is mandatory, but I plan to make it optional in future updates. To enable geolocation, you will need an [API key from ipapi](https://ipapi.com), which is free to obtain. +## Testing -## Gather.sh +Linkarooie uses RSpec for testing. The test suite includes: +- Model specs +- Controller specs +- Feature specs +- Helper specs -I wrote a script to gather information that is useful to copy paste into things like ChatGPT or Cluade. +To run the entire test suite: ```bash -➜ linkarooie git:(dl/public-analytics-page) ✗ ./gather.sh --help -Usage: ./gather.sh [-o output_method] [-f output_file] - -o, --output Output method: stdout, clipboard, or file (default: stdout) - -f, --file Output file path (required if output method is file) - ``` +bundle exec rspec +``` -## Customization +To run specific tests: -Linkarooie is highly customizable: +```bash +bundle exec rspec spec/models +bundle exec rspec spec/controllers +bundle exec rspec spec/features +``` -* **Views:** Modify the HTML/CSS in the `app/views` directory. -* **Styles:** Customize the look and feel using Tailwind CSS. -* **Features:** Add or modify features in the `app/controllers` and `app/models` directories. +## CI/CD -## Testing +Linkarooie utilizes GitHub Actions for continuous integration and deployment: + +1. **CI Workflow** (`ci.yml`): + - Triggered on pull requests to `main` and pushes to feature branches + - Sets up Ruby and Node.js environments + - Installs dependencies + - Runs tests + - Builds and pushes Docker image to GitHub Container Registry -Run the test suite with: +2. **Deployment Workflow** (`deploy.yml`): + - Triggered on merges to `main` or manual dispatch + - Builds and pushes Docker image + - SSHs into the DigitalOcean Droplet + - Pulls the latest Docker image + - Runs database migrations + - Restarts the application containers + +## Project Structure + +``` +linkarooie/ +├── app/ +│ ├── assets/ +│ ├── controllers/ +│ ├── helpers/ +│ ├── javascript/ +│ ├── jobs/ +│ ├── mailers/ +│ ├── models/ +│ ├── services/ +│ └── views/ +├── bin/ +├── config/ +├── db/ +├── lib/ +├── public/ +├── spec/ +├── storage/ +├── terraform/ +│ ├── droplet/ +│ └── spaces/ +├── .github/workflows/ +├── Dockerfile +├── docker-compose.prod.yml +├── Gemfile +├── package.json +└── ... +``` + +## Key Components + +- **User Model:** Manages user accounts, profiles, and authentication. +- **Link Model:** Handles the creation and management of user links. +- **Achievement Model:** Manages user achievements and milestones. +- **Analytics:** Tracks and stores user engagement metrics. +- **OpenGraphImageGenerator:** Service for creating social media preview images. +- **BackupDatabaseJob:** Manages automated database backups. + +## Gather Script + +The `gather.sh` script is a utility for collecting project information: ```bash -rspec +./gather.sh [-o output_method] [-f output_file] + -o, --output Output method: stdout, clipboard, or file (default: stdout) + -f, --file Output file path (required if output method is file) ``` +This script is useful for quickly compiling project details for documentation or sharing. + ## Contributing -Contributions are welcome! Please follow these steps: +We welcome contributions to Linkarooie! Here's how you can help: 1. Fork the repository -2. Create your feature branch (`git checkout -b feature/AmazingFeature`) -3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the branch (`git push origin feature/AmazingFeature`) +2. Create your feature branch: `git checkout -b feature/AmazingFeature` +3. Commit your changes: `git commit -m 'Add some AmazingFeature'` +4. Push to the branch: `git push origin feature/AmazingFeature` 5. Open a Pull Request Please ensure your code adheres to the existing style and passes all tests. @@ -249,11 +421,12 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Support -If you find this project helpful, please consider: +If you find Linkarooie helpful, please consider: - Starring the repository on GitHub -- Sharing it with others -- Contributing to the project +- Sharing the project with others +- Contributing to the codebase +- Reporting issues or suggesting improvements ## Acknowledgements @@ -262,7 +435,12 @@ If you find this project helpful, please consider: - [Docker](https://www.docker.com/) - [DigitalOcean](https://www.digitalocean.com/) - [Terraform](https://www.terraform.io/) +- [Vite](https://vitejs.dev/) +- [Sidekiq](https://sidekiq.org/) +- [Devise](https://github.com/heartcombo/devise) +- [Chartkick](https://chartkick.com/) +- [Geocoder](https://github.com/alexreisner/geocoder) --- -Linkarooie © 2024 - Simplify Your Online Presence +Linkarooie © 2024 - Simplify Your Online Presence \ No newline at end of file From 235fe956eac62250144ba0a9dadb710a5639ccea Mon Sep 17 00:00:00 2001 From: Dean Lofts Date: Sat, 24 Aug 2024 17:00:58 +1000 Subject: [PATCH 7/9] public analytics --- app/controllers/analytics_controller.rb | 35 ++- .../users/registrations_controller.rb | 8 +- app/javascript/entrypoints/application.js | 2 + app/views/analytics/index.html.erb | 215 +++++++++--------- app/views/devise/registrations/edit.html.erb | 5 + app/views/layouts/application.html.erb | 2 +- app/views/links/user_links.html.erb | 9 + config/routes.rb | 2 +- ...824060759_add_public_analytics_to_users.rb | 5 + db/schema.rb | 3 +- public/uploads/og_images/loftwah_og.png | Bin 95789 -> 95789 bytes 11 files changed, 168 insertions(+), 118 deletions(-) create mode 100644 db/migrate/20240824060759_add_public_analytics_to_users.rb diff --git a/app/controllers/analytics_controller.rb b/app/controllers/analytics_controller.rb index 912687b..332646d 100644 --- a/app/controllers/analytics_controller.rb +++ b/app/controllers/analytics_controller.rb @@ -1,12 +1,14 @@ class AnalyticsController < ApplicationController - before_action :authenticate_user! + before_action :set_user + before_action :check_analytics_visibility + CACHE_EXPIRATION = 30.minutes # You can adjust this or move it to an initializer def index - @total_page_views = fetch_cached_data("total_page_views") { current_user.page_views.count } - @total_link_clicks = fetch_cached_data("total_link_clicks") { current_user.link_clicks.count } - @total_achievement_views = fetch_cached_data("total_achievement_views") { current_user.achievement_views.count } - @unique_visitors = fetch_cached_data("unique_visitors") { current_user.page_views.select(:ip_address).distinct.count } - @latest_daily_metric = fetch_cached_data("latest_daily_metric") { current_user.daily_metrics.order(date: :desc).first } + @total_page_views = fetch_cached_data("total_page_views") { @user.page_views.count } + @total_link_clicks = fetch_cached_data("total_link_clicks") { @user.link_clicks.count } + @total_achievement_views = fetch_cached_data("total_achievement_views") { @user.achievement_views.count } + @unique_visitors = fetch_cached_data("unique_visitors") { @user.page_views.select(:ip_address).distinct.count } + @latest_daily_metric = fetch_cached_data("latest_daily_metric") { @user.daily_metrics.order(date: :desc).first } @link_analytics = fetch_cached_data("link_analytics") { fetch_link_analytics } @achievement_analytics = fetch_cached_data("achievement_analytics") { fetch_achievement_analytics } @daily_views = fetch_cached_data("daily_views") { fetch_daily_views } @@ -15,12 +17,23 @@ def index private + def set_user + @user = User.find_by!(username: params[:username]) + end + + def check_analytics_visibility + unless @user == current_user || @user.public_analytics? + flash[:alert] = "This user's analytics are not public." + redirect_to root_path + end + end + def fetch_cached_data(key, &block) Rails.cache.fetch("#{cache_key_with_version}/#{key}", expires_in: CACHE_EXPIRATION, &block) end def fetch_link_analytics - current_user.links.includes(:link_clicks).map do |link| + @user.links.includes(:link_clicks).map do |link| { id: link.id, title: link.title, @@ -31,7 +44,7 @@ def fetch_link_analytics end def fetch_achievement_analytics - current_user.achievements.includes(:achievement_views).map do |achievement| + @user.achievements.includes(:achievement_views).map do |achievement| { id: achievement.id, title: achievement.title, @@ -42,11 +55,11 @@ def fetch_achievement_analytics end def fetch_daily_views - current_user.page_views.group_by_day(:visited_at, range: 30.days.ago..Time.now).count + @user.page_views.group_by_day(:visited_at, range: 30.days.ago..Time.now).count end def fetch_browser_data - current_user.page_views.group(:browser).count.transform_keys do |user_agent| + @user.page_views.group(:browser).count.transform_keys do |user_agent| case user_agent when /Chrome/ 'Chrome' @@ -85,6 +98,6 @@ def fetch_browser_data end def cache_key_with_version - "user_#{current_user.id}_analytics_v1" + "user_#{@user.id}_analytics_v1" end end \ No newline at end of file diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index e2ca07e..f965ca6 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -63,8 +63,12 @@ def sign_up_params end def account_update_params - params.require(:user).permit(:email, :password, :password_confirmation, :current_password, :username, :full_name, :tags, :avatar, :banner, :description).tap do |user_params| - user_params[:tags] = user_params[:tags].split(',').map(&:strip).to_json if user_params[:tags].present? + params.require(:user).permit(:email, :password, :password_confirmation, :current_password, + :username, :full_name, :avatar, :banner, :description, :tags, + :public_analytics).tap do |user_params| + if user_params[:tags].present? + user_params[:tags] = user_params[:tags].split(',').map(&:strip).to_json + end end end end diff --git a/app/javascript/entrypoints/application.js b/app/javascript/entrypoints/application.js index 015880c..0111015 100644 --- a/app/javascript/entrypoints/application.js +++ b/app/javascript/entrypoints/application.js @@ -5,6 +5,8 @@ // <%= vite_javascript_tag 'application' %> import Rails from "@rails/ujs"; import "chartkick/chart.js" +import "flowbite"; + Rails.start(); console.log('Vite ⚡️ Rails') diff --git a/app/views/analytics/index.html.erb b/app/views/analytics/index.html.erb index 022dfb4..3fd0fdf 100644 --- a/app/views/analytics/index.html.erb +++ b/app/views/analytics/index.html.erb @@ -1,21 +1,33 @@
-

Your Analytics Dashboard

+

+ Analytics for <%= @user.username %> + <% if @user.public_analytics? %> + + Public + + <% else %> + + Private + + <% end %> +

+
-
- <% metrics = [ - { title: "Total Page Views", value: @total_page_views }, - { title: "Total Link Clicks", value: @total_link_clicks }, - { title: "Total Achievement Views", value: @total_achievement_views }, - { title: "Unique Visitors", value: @unique_visitors } - ] %> - <% metrics.each do |metric| %> -
-

<%= metric[:title] %>

-

<%= number_with_delimiter(metric[:value]) %>

-
- <% end %> -
+
+ <% metrics = [ + { title: "Total Page Views", value: @total_page_views }, + { title: "Total Link Clicks", value: @total_link_clicks }, + { title: "Total Achievement Views", value: @total_achievement_views }, + { title: "Unique Visitors", value: @unique_visitors } + ] %> + <% metrics.each do |metric| %> +
+

<%= metric[:title] %>

+

<%= number_with_delimiter(metric[:value]) %>

+
+ <% end %> +
<% if @latest_daily_metric %> @@ -43,102 +55,101 @@ <% end %> -
-

Link Analytics

-

Performance data for your links. 'Total Clicks' represents all interactions, while 'Unique Visitors' counts individual users.

-
- - - - - - - - - - <% @link_analytics.each_with_index do |link, index| %> - - - - +
+

Link Analytics

+

Performance data for links. 'Total Clicks' represents all interactions, while 'Unique Visitors' counts individual users.

+
+
LinkTotal ClicksUnique Visitors
- <%= link[:title] %> - <%= number_with_delimiter(link[:total_clicks]) %><%= number_with_delimiter(link[:unique_visitors]) %>
+ + + + + - <% end %> - -
LinkTotal ClicksUnique Visitors
+ + + <% @link_analytics.each_with_index do |link, index| %> + + + <%= link[:title] %> + + <%= number_with_delimiter(link[:total_clicks]) %> + <%= number_with_delimiter(link[:unique_visitors]) %> + + <% end %> + + +
- - -
-

Achievement Analytics

-

Performance data for your achievements. 'Total Views' represents all interactions, while 'Unique Viewers' counts individual users.

-
- - - - - - - - - - <% @achievement_analytics.each_with_index do |achievement, index| %> - - - - + +
+

Achievement Analytics

+

Performance data for achievements. 'Total Views' represents all interactions, while 'Unique Viewers' counts individual users.

+
+
AchievementTotal ViewsUnique Viewers
- <%= achievement[:title] %> - <%= number_with_delimiter(achievement[:total_views]) %><%= number_with_delimiter(achievement[:unique_viewers]) %>
+ + + + + - <% end %> - -
AchievementTotal ViewsUnique Viewers
+ + + <% @achievement_analytics.each_with_index do |achievement, index| %> + + + <%= achievement[:title] %> + + <%= number_with_delimiter(achievement[:total_views]) %> + <%= number_with_delimiter(achievement[:unique_viewers]) %> + + <% end %> + + +
- -
- - -
-

Daily Views (Last 30 Days)

- <%= line_chart @daily_views, - colors: ["#84CC16"], - library: { - backgroundColor: 'transparent', - legend: { display: false }, - scales: { - x: { ticks: { color: 'white', fontSize: 12 } }, - y: { ticks: { color: 'white', fontSize: 12 } }, - }, - elements: { - line: { tension: 0.4 }, # smooth curves - point: { radius: 3, backgroundColor: 'white' } +
+ +
+

Daily Views (Last 30 Days)

+ <%= line_chart @daily_views, + colors: ["#84CC16"], + library: { + backgroundColor: 'transparent', + legend: { display: false }, + scales: { + x: { ticks: { color: 'white', fontSize: 12 } }, + y: { ticks: { color: 'white', fontSize: 12 } }, + }, + elements: { + line: { tension: 0.4 }, + point: { radius: 3, backgroundColor: 'white' } + }, + title: { display: true, text: 'Daily Views (Last 30 Days)', color: 'white', fontSize: 16 }, + responsive: true }, - title: { display: true, text: 'Daily Views (Last 30 Days)', color: 'white', fontSize: 16 }, - responsive: true - }, - height: "300px" %> + height: "300px" %> +
-
- -
-
-

Browser Usage

- <%= pie_chart @browser_data, - colors: ["#84CC16", "#22D3EE", "#E879F9", "#F87171", "#A78BFA", "#F59E0B", "#14B8A6", "#3B82F6"], - library: { - backgroundColor: 'transparent', - legend: { position: 'bottom', labels: { color: 'white', fontSize: 12 } }, - title: { display: true, text: 'Browser Usage', color: 'white', fontSize: 16 }, - responsive: true, - plugins: { datalabels: { color: 'white', font: { weight: 'bold' } } } - }, - donut: true, - height: "250px" %> +
+
+

Browser Usage

+ <%= pie_chart @browser_data, + colors: ["#84CC16", "#22D3EE", "#E879F9", "#F87171", "#A78BFA", "#F59E0B", "#14B8A6", "#3B82F6"], + library: { + backgroundColor: 'transparent', + legend: { position: 'bottom', labels: { color: 'white', fontSize: 12 } }, + title: { display: true, text: 'Browser Usage', color: 'white', fontSize: 16 }, + responsive: true, + plugins: { datalabels: { color: 'white', font: { weight: 'bold' } } } + }, + donut: true, + height: "250px" %> +
-
+
\ No newline at end of file diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index d8ccbc0..2c3853f 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -38,6 +38,11 @@ <%= f.text_area :description, class: 'w-full p-1 rounded text-black' %>
+
+ <%= f.label :public_analytics %> + <%= f.check_box :public_analytics %> +
+
<%= f.label :password, class: 'block text-gray-300 mb-1' %> <% if @minimum_password_length %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 239af23..5f97e35 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -65,7 +65,7 @@
+ <% if @pinned_links.any? %>