Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.development.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# .env.development.example
# Example environment variables for local development

DATABASE_URL=postgresql://postgres:starter@localhost:5432/hc_rails_starter_development
# HCA OAuth
HCA_CLIENT_ID=
HCA_CLIENT_SECRET=
HCA_COMMUNITY=1 # this allows for testing with non-HQ HCA applications.
# It removes phone numbers, birthdays and addresses from oauth scopes.

# Slack bot token for profile sync
SLACK_BOT_TOKEN=

# Geocoding API (Hack Club service)
GEOCODER_API_KEY=

# External API key for /api/v1 endpoints
EXTERNAL_API_KEY=dev_api_key

# Optional: URL to ping for uptime monitoring (leave empty if not using)
# UPTIME_WORKER_PING_URL=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ node_modules/
.env
.env.*
!.env.example
!.env.development.example

# Database
/db/*.sqlite3
Expand Down
117 changes: 8 additions & 109 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,115 +1,14 @@
# Hack Club Rails Starter Template
# Forge

This template is a very opinionated starting point for my Hack Club programs written in Rails. It is heavily inspired by, with code used from, previous Hack Club programs and services such as the Summer of Making, HCB, and Submit, as well as [24c02/thirdrail](https://github.com/24c02/thirdrail).
Welcome to Forge! Forge is a [Hack Club](https://hackclub.com) YSWS program for teens to build hardware projects.

Is it good? I don't know, but it's good enough for me.
[Start forgin' it](https://forge.hackclub.com/?utm_source=github_readme)

## Features
## Security

- HCA OAuth authentication with automatic Slack profile sync
- Role-based access control (Admin, Reviewer, User) via Pundit
- Project CRUD with tagging, visibility controls, and soft delete
- Ship review workflow (pending, approved, returned, rejected) with frozen data snapshots and encrypted storage
- Admin dashboard for managing users, projects, ships, and background jobs
- Audit trails via PaperTrail
- File-based Markdown documentation with caching and auto-generated navigation
- Ahoy analytics with visit tracking, geolocation, and UTM attribution
- Rack::Attack rate limiting and request filtering
- Solid Queue, Solid Cache, and Solid Cable for jobs, caching, and WebSockets
- Inertia.js with React 19, Vite, and TypeScript
- Tailwind CSS 4
- Sentry error tracking, Skylight performance monitoring
- Active Storage with Cloudflare R2 and image variant processing
- Kamal deployment with Thruster for HTTP caching/compression
Security vulnerabilities should never be reported on slack or via github issues! Please report all security issues to [https://security.hackclub.com](https://security.hackclub.com)

## Local Development Setup
## Contributing
Please see [the contributing tab](https://github.com/hackclub/forge?tab=contributing-ov-file#)

### 1. Prerequisites

- Ruby (see `.ruby-version` or Gemfile)
- Node.js (for Vite and frontend dependencies)
- Bundler (`gem install bundler`)
- Docker (for running Postgres)

### 2. Start Postgres with Docker

You can spin up a local Postgres instance using Docker:

```sh
docker run -d \
--name hc-rails-starter-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=starter \
-e POSTGRES_DB=hc_rails_starter_development \
-p 5432:5432 \
postgres:15
```

Update your `.env` file with the database URL:

```
DATABASE_URL=postgresql://postgres:starter@localhost:5432/hc_rails_starter_development
```

### 3. Install dependencies

```sh
bundle install
npm install
```

### 4. Setup credentials

The template ships with a placeholder `config/credentials.yml.enc`. Delete it and generate fresh credentials for your project:

```sh
rm config/credentials.yml.enc
bin/rails credentials:edit
```

Then generate Active Record encryption keys and paste them into the credentials file:

```sh
bin/rails db:encryption:init
```

Copy the output into your credentials file so it looks like:

```yaml
active_record_encryption:
primary_key: <generated>
deterministic_key: <generated>
key_derivation_salt: <generated>
```

This creates `config/master.key` (keep this secret, never commit it) and a new `config/credentials.yml.enc`.

### 5. Setup the database

```sh
bin/rails db:setup
```

### 6. Start the Rails server

```sh
bin/dev
```

### Cloudflare R2 (Production)

Active Storage is configured to use Cloudflare R2 in production. Development uses local disk storage by default. To set up R2 for production, create an R2 bucket and API token in the Cloudflare dashboard, then set these environment variables:

```
R2_ACCESS_KEY_ID=your_access_key_id
R2_SECRET_ACCESS_KEY=your_secret_access_key
R2_BUCKET=your_bucket_name
R2_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
```

---

See `.env.development.example` for required environment variables.


cacaw
We're always looking for people to help with the platform! PRs are welcome. Local development and deployment instructions can be found [here](https://github.com/hackclub/forge/blob/main/development.md). PRs are welcome!
2 changes: 1 addition & 1 deletion app/controllers/markdown_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def show
content_html = helpers.render_markdown_file(path)
file_meta = helpers.guide_metadata_for(path)
meta = helpers.docs_meta_for_url("/docs#{slug == 'index' ? '' : "/#{slug}"}")
page_title = file_meta[:title].presence || meta&.dig(:title).presence || slug.tr("-_/", " ").split.map(&:capitalize).join(" ")
page_title = meta&.dig(:title).presence || file_meta[:title].presence || slug.tr("-_/", " ").split.map(&:capitalize).join(" ")

render inertia: "Markdown/Show", props: {
content_html: content_html,
Expand Down
137 changes: 103 additions & 34 deletions app/helpers/markdown_helper.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require "redcarpet"
require "uri"
require "digest"
require "yaml"

module MarkdownHelper
def self.canonical_base_url
Expand Down Expand Up @@ -116,7 +117,8 @@ def render_markdown_file(path, base_url: nil)

def docs_metadata(base:, url_prefix:, default_index_title: "")
paths = Dir.glob(base.join("**/*.md").to_s)
stats = paths.map { |p| [ p, File.mtime(p).to_i ] }.sort_by(&:first)
config_paths = Dir.glob(base.join("**/config.yaml").to_s)
stats = (paths + config_paths).uniq.map { |p| [ p, File.mtime(p).to_i ] }.sort_by(&:first)
return build_docs_metadata(base, url_prefix, default_index_title, paths) if Rails.env.development?

stats_digest = Digest::SHA256.hexdigest(stats.flatten.join("|"))
Expand Down Expand Up @@ -147,14 +149,15 @@ def build_docs_metadata(base, url_prefix, default_index_title, paths)
end

meta = parse_guide_metadata(p)
meta = merge_docs_metadata(meta, docs_config_metadata_for_path(p)) if File.basename(p) == "index.md"
fallback_title = if slug.blank?
default_index_title
else
slug.tr("-_/", " ").split.map(&:capitalize).join(" ")
end
title = meta[:title].presence || fallback_title
desc = meta[:description].presence
prio = meta[:priority]
prio = meta[:priority].nil? ? 0 : meta[:priority].to_i
unlisted = meta[:unlisted] || false
items << { title: title, path: url, description: desc, slug: slug, file: p, priority: prio, unlisted: unlisted }
end
Expand All @@ -167,9 +170,8 @@ def docs_section_metadata
end

def docs_menu_items
docs_section_metadata
sorted_docs_items(docs_section_metadata)
.reject { |i| i[:slug].blank? || i[:unlisted] }
.sort_by { |h| [ h[:priority].nil? ? Float::INFINITY : h[:priority].to_i, h[:title].downcase ] }
.map { |i| { title: i[:title], path: i[:path], description: i[:description] } }
end

Expand All @@ -191,9 +193,8 @@ def menu_items_for(url_path)
base = Rails.root.join("docs", section)
return [] unless File.directory?(base)

docs_metadata(base: base, url_prefix: url_path, default_index_title: section.titleize)
sorted_docs_items(docs_metadata(base: base, url_prefix: url_path, default_index_title: section.titleize))
.reject { |i| i[:slug].blank? || i[:unlisted] }
.sort_by { |h| [ h[:priority].nil? ? Float::INFINITY : h[:priority].to_i, h[:title].downcase ] }
.map { |i| { title: i[:title], path: i[:path], description: i[:description] } }
end

Expand All @@ -212,41 +213,109 @@ def build_docs_sidebar_nodes(base, rel_dir)
dir = rel_dir.blank? ? base : base.join(rel_dir)
entries = Dir.children(dir).sort_by(&:downcase)

folders = entries.select { |name| File.directory?(dir.join(name)) }
files = entries.select { |name| name.end_with?(".md") }

folder_nodes = folders.map do |folder_name|
child_rel_dir = rel_dir.blank? ? folder_name : File.join(rel_dir, folder_name)
child_nodes = build_docs_sidebar_nodes(base, child_rel_dir)
folder_index_path = dir.join(folder_name, "index.md")
folder_meta_title = File.exist?(folder_index_path) ? parse_guide_metadata(folder_index_path)[:title].presence : nil

{
type: "folder",
title: folder_meta_title || humanize_sidebar_name(folder_name),
path: docs_path_for_sidebar(child_rel_dir),
children: child_nodes
}
nodes = entries.filter_map do |name|
entry_path = dir.join(name)

if File.directory?(entry_path)
child_rel_dir = rel_dir.blank? ? name : File.join(rel_dir, name)
folder_meta = docs_directory_metadata(entry_path)

{
type: "folder",
title: folder_meta[:title].presence || humanize_sidebar_name(name),
path: docs_path_for_sidebar(child_rel_dir),
priority: folder_meta[:priority].nil? ? 0 : folder_meta[:priority].to_i,
children: build_docs_sidebar_nodes(base, child_rel_dir)
}
elsif name.end_with?(".md") && !(rel_dir.present? && File.basename(name, ".md") == "index")
file_rel_path = rel_dir.blank? ? name : File.join(rel_dir, name)
file_meta = docs_page_metadata(entry_path)

{
type: "page",
title: file_meta[:title].presence || humanize_sidebar_name(File.basename(name, ".md")),
path: docs_path_for_sidebar(file_rel_path),
priority: file_meta[:priority].nil? ? 0 : file_meta[:priority].to_i
}
end
end

if rel_dir.blank?
sorted_docs_items(nodes.select { |item| item[:type] == "page" }) +
sorted_docs_items(nodes.select { |item| item[:type] == "folder" })
else
sorted_docs_items(nodes)
end
end

def sorted_docs_items(items)
items.sort_by { |item| [ -(item[:priority] || 0).to_i, item[:title].to_s.downcase, item[:path].to_s ] }
end

def docs_page_metadata(path)
meta = parse_guide_metadata(path)
return meta unless File.basename(path) == "index.md"

merge_docs_metadata(meta, docs_config_metadata_for_path(path))
end

def docs_directory_metadata(dir)
index_path = dir.join("index.md")
index_meta = File.exist?(index_path) ? parse_guide_metadata(index_path) : {}

merge_docs_metadata(index_meta, docs_config_metadata(dir))
end

def docs_config_metadata_for_path(path)
docs_config_metadata(Pathname.new(path).dirname)
end

visible_files = files.reject { |file_name| rel_dir.present? && File.basename(file_name, ".md") == "index" }
def docs_config_metadata(dir)
config_path = dir.join("config.yaml")
return default_docs_metadata unless File.exist?(config_path)

sorted_files = visible_files.sort_by do |file_name|
[ File.basename(file_name, ".md") == "index" ? 0 : 1, file_name.downcase ]
if Rails.env.development?
build_docs_config_metadata(config_path)
else
key = [ "docs_yaml_meta", config_path.to_s, File.mtime(config_path).to_i ]
Rails.cache.fetch(key) { build_docs_config_metadata(config_path) }
end
end

def build_docs_config_metadata(config_path)
raw = YAML.safe_load(File.read(config_path))
data = raw.is_a?(Hash) ? raw : {}

{
title: data["title"].presence,
description: data["description"].presence,
priority: parse_priority_value(data["priority"])
}
rescue Errno::ENOENT, Psych::SyntaxError
default_docs_metadata
end

page_nodes = sorted_files.map do |file_name|
file_rel_path = rel_dir.blank? ? file_name : File.join(rel_dir, file_name)
file_path = dir.join(file_name)
meta_title = parse_guide_metadata(file_path)[:title].presence
{
type: "page",
title: meta_title || humanize_sidebar_name(File.basename(file_name, ".md")),
path: docs_path_for_sidebar(file_rel_path)
}
def merge_docs_metadata(*sources)
merged = default_docs_metadata

sources.compact.each do |source|
merged[:title] = source[:title] if source[:title].present?
merged[:description] = source[:description] if source[:description].present?
merged[:priority] = source[:priority] unless source[:priority].nil?
merged[:unlisted] = true if source[:unlisted]
end

rel_dir.blank? ? (page_nodes + folder_nodes) : (folder_nodes + page_nodes)
merged
end

def parse_priority_value(value)
Integer(value)
rescue ArgumentError, TypeError
nil
end

def default_docs_metadata
{ title: nil, description: nil, priority: nil, unlisted: false }
end

def docs_path_for_sidebar(rel_path)
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/pages/Projects/Show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ export default function ProjectsShow({
<>
<p className="text-stone-400 text-sm mb-2">No journal entries yet.</p>
<p className="text-stone-500 text-xs">Add a <code className="text-[#ffb595]">JOURNAL.md</code> to your repo and click "Sync JOURNAL.md" above.</p>
<a href="/docs/Requirements-and-Shipping/journal-format" className="text-[#ffb595] text-xs hover:underline mt-2 inline-block">See the format guide</a>
<a href="/docs/requirements/journal-format" className="text-[#ffb595] text-xs hover:underline mt-2 inline-block">See the format guide</a>
</>
) : (
<p className="text-stone-400 text-sm">{project.user_display_name} hasn't added a journal entry yet.</p>
Expand Down
Loading
Loading