diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..0c1eac3 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,206 @@ +# GitStore Rails 8 Examples + +This directory contains complete Rails 8 example applications demonstrating the key use cases for GitStore. + +## 📚 Available Examples + +### 1. Wiki/CMS (`wiki_cms/`) + +A complete wiki application with versioned content management. + +**Features:** +- Create and edit wiki pages with Markdown support +- Full version history with diff viewing +- Rollback to any previous version +- Wiki-style `[[Page Links]]` +- Search functionality + +**Key files:** +- `app/models/page.rb` - Page model with GitStore integration +- `app/controllers/pages_controller.rb` - Full CRUD with history +- `app/views/pages/` - Views with Tailwind CSS + +[Read more →](wiki_cms/README.md) + +--- + +### 2. Configuration Management (`config_management/`) + +Version-controlled application configuration storage. + +**Features:** +- YAML/JSON configuration storage +- Environment-specific configs +- Instant rollback for bad configurations +- Diff viewing between versions +- API access for programmatic use + +**Key files:** +- `app/models/config_entry.rb` - Config model with versioning +- `app/controllers/configs_controller.rb` - Config management +- `app/services/` - (Optional) Config validation services + +[Read more →](config_management/README.md) + +--- + +### 3. Audit Trail (`audit_trail/`) + +Tamper-evident data storage for compliance requirements. + +**Features:** +- Immutable audit trail +- Point-in-time data reconstruction +- Cryptographic integrity verification +- Compliance report generation +- Existence proofs for legal/regulatory needs + +**Key files:** +- `app/models/audited_record.rb` - Record with full audit capabilities +- `app/services/audit_service.rb` - Report generation and compliance tools +- `app/controllers/records_controller.rb` - CRUD with audit features + +[Read more →](audit_trail/README.md) + +--- + +## 🚀 Quick Start + +Each example can be integrated into a new Rails 8 application: + +```bash +# Create a new Rails 8 app +rails new my_app --database=sqlite3 --css=tailwind + +# Add git_store to your Gemfile +cd my_app +echo "gem 'git_store', github: 'georgi/git_store'" >> Gemfile +bundle install + +# Copy example files +cp -r /path/to/examples/wiki_cms/app/* app/ +cp -r /path/to/examples/wiki_cms/config/* config/ + +# Initialize the content repository +mkdir -p content_repo +cd content_repo && git init && cd .. + +# Start the server +rails server +``` + +## 📁 Common Patterns + +### GitStore Initialization + +All examples use a similar pattern for initializing GitStore in Rails: + +```ruby +# config/initializers/git_store.rb +Rails.application.config.to_prepare do + repo_path = Rails.root.join('data_repo') + + unless File.exist?(repo_path.join('.git')) + FileUtils.mkdir_p(repo_path) + Dir.chdir(repo_path) do + system('git init') + system('git config user.name "App System"') + system('git config user.email "system@example.com"') + end + end + + Rails.application.config.store = GitStore.new(repo_path.to_s) +end +``` + +### Model Pattern + +The examples use a consistent model pattern: + +```ruby +class MyModel + include ActiveModel::Model + include ActiveModel::Attributes + + class << self + def store + Rails.application.config.store + end + + def find(id) + data = store["records/#{id}.yml"] + return nil unless data + new(data.merge(id: id)) + end + end + + def save(author: nil, message: nil) + store.transaction(message, git_author(author)) do + store["records/#{id}.yml"] = to_hash + end + end +end +``` + +### Transaction Pattern + +For atomic operations: + +```ruby +store.transaction("Update multiple configs", author) do + store["config/feature_a.yml"] = { enabled: true } + store["config/feature_b.yml"] = { enabled: false } + # All changes committed together, or all rolled back on error +end +``` + +## 🔑 Key Benefits + +| Feature | Traditional DB | GitStore | +|---------|---------------|----------| +| Version History | Custom implementation | Built-in (Git) | +| Rollback | Complex | One command | +| Diff/Comparison | Custom | Git diff | +| Branching | Not available | Git branches | +| Audit Trail | Extra tables | Every commit | +| External Editing | Not possible | Edit files directly | +| Backup | DB dumps | Git clone | + +## 💡 When to Use GitStore + +**Good for:** +- Content Management Systems +- Configuration storage +- Audit-critical data +- Documentation systems +- Data that changes infrequently +- Human-readable data storage + +**Not ideal for:** +- High-frequency writes +- Complex relational queries +- Large binary files +- Real-time data + +## 🔒 Security Notes + +When integrating these examples into your Rails application: + +1. **CSRF Protection** - Ensure your `ApplicationController` has CSRF protection enabled: + ```ruby + class ApplicationController < ActionController::Base + protect_from_forgery with: :exception + end + ``` + +2. **Authentication** - Add proper authentication before allowing data modifications +3. **Authorization** - Implement role-based access control for sensitive operations +4. **Input Validation** - The examples include basic validation; add more as needed +5. **Repository Access** - Restrict file system access to the Git repositories + +## 📖 Further Reading + +- [GitStore README](../README.md) +- [GitStore Documentation](../docs/) +- [Shinmun Blog Engine](http://www.matthias-georgi.de/shinmun) - Inspiration for GitStore +- [Rails Security Guide](https://guides.rubyonrails.org/security.html) diff --git a/examples/audit_trail/README.md b/examples/audit_trail/README.md new file mode 100644 index 0000000..585c035 --- /dev/null +++ b/examples/audit_trail/README.md @@ -0,0 +1,176 @@ +# GitStore Audit Trail Example + +A Rails 8 application demonstrating versioned data storage with complete audit capabilities powered by GitStore. + +## Features + +- **Immutable audit trail** - Every change is a Git commit that can't be tampered with +- **Point-in-time reconstruction** - Reconstruct data state at any moment in history +- **Complete provenance** - Track who changed what, when, and why +- **Compliance ready** - Perfect for SOX, HIPAA, GDPR audit requirements +- **Cryptographic integrity** - Git SHA ensures data hasn't been altered + +## Quick Start + +```bash +# Create a new Rails 8 application +rails new audit_app --database=sqlite3 --css=tailwind + +# Add git_store to Gemfile +echo "gem 'git_store', path: '../../../'" >> Gemfile + +# Install dependencies +bundle install + +# Copy the example files +cp -r app/* audit_app/app/ +cp -r config/* audit_app/config/ + +# Initialize the audit repository +mkdir -p audit_app/audit_repo +cd audit_app/audit_repo && git init + +# Run the application +cd audit_app && rails server +``` + +## Use Cases + +### 1. Financial Transaction Auditing + +```ruby +# Record a financial transaction with full audit trail +AuditedRecord.create( + type: 'financial_transaction', + id: 'txn_12345', + data: { + amount: 1000.00, + currency: 'USD', + from_account: 'ACC001', + to_account: 'ACC002', + description: 'Wire transfer' + }, + author: current_user, + message: 'Initiated wire transfer' +) + +# Later, if we need to prove the transaction existed at a specific time +record = AuditedRecord.find('financial_transaction', 'txn_12345') +proof = record.existence_proof +# => { commit_id: 'abc123...', timestamp: '2024-01-15T10:30:00Z', ... } +``` + +### 2. Medical Record Management (HIPAA Compliance) + +```ruby +# Store patient data with audit trail +AuditedRecord.create( + type: 'patient_record', + id: 'patient_001', + data: { + name: 'John Doe', + dob: '1990-01-15', + diagnoses: ['Type 2 Diabetes'], + medications: ['Metformin 500mg'] + }, + author: current_user, + message: 'Initial patient registration' +) + +# Track who accessed the record +AuditService.log_access( + record_type: 'patient_record', + record_id: 'patient_001', + user: current_user, + action: 'view' +) + +# Generate HIPAA audit report +report = AuditService.generate_report( + record_type: 'patient_record', + record_id: 'patient_001', + from: 30.days.ago, + to: Time.current +) +``` + +### 3. Document Version Control + +```ruby +# Store a legal document +AuditedRecord.create( + type: 'legal_document', + id: 'contract_2024_001', + data: { + title: 'Service Agreement', + version: '1.0', + content: contract_text, + parties: ['Company A', 'Company B'] + }, + author: current_user, + message: 'Initial draft of service agreement' +) + +# Get the complete revision history +record = AuditedRecord.find('legal_document', 'contract_2024_001') +record.revisions.each do |revision| + puts "Version at #{revision.timestamp}: #{revision.message}" + puts "Changed by: #{revision.author}" +end +``` + +## Compliance Features + +### Tamper-Evident Storage + +Every record is stored with a cryptographic hash (Git SHA). Any modification to historical data would change the hash, making tampering detectable. + +```ruby +# Verify data integrity +record = AuditedRecord.find('financial_transaction', 'txn_12345') +record.verify_integrity! +# => true (data hasn't been tampered with) +``` + +### Point-in-Time Queries + +Reconstruct the exact state of data at any historical moment: + +```ruby +# What did this record look like last week? +record = AuditedRecord.find('patient_record', 'patient_001') +historical_state = record.state_at(1.week.ago) +``` + +### Audit Log Generation + +```ruby +# Generate comprehensive audit log +log = AuditService.generate_log( + from: 1.month.ago, + to: Time.current, + record_types: ['financial_transaction', 'patient_record'], + format: :csv +) +``` + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | /records | List all audited records | +| GET | /records/:type/:id | Get a specific record | +| POST | /records | Create a new record | +| PATCH | /records/:type/:id | Update a record | +| GET | /records/:type/:id/history | Full revision history | +| GET | /records/:type/:id/state_at | State at a point in time | +| GET | /records/:type/:id/proof | Existence proof for compliance | +| GET | /audit_logs | Search audit logs | +| GET | /audit_logs/report | Generate audit report | + +## Security Considerations + +1. **Repository Protection** - The audit repository should be protected with restricted access +2. **Backup Strategy** - Regular backups of the Git repository ensure durability +3. **Key Management** - Consider signing commits for additional integrity +4. **Access Logging** - All API access should be logged separately diff --git a/examples/audit_trail/app/controllers/records_controller.rb b/examples/audit_trail/app/controllers/records_controller.rb new file mode 100644 index 0000000..ab12817 --- /dev/null +++ b/examples/audit_trail/app/controllers/records_controller.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +# Controller for audited records with compliance features. +# Provides CRUD operations, history, and compliance proof generation. +# +# NOTE: This controller expects to inherit from ApplicationController which should +# have CSRF protection enabled (protect_from_forgery with: :exception). +# See Rails security guide: https://guides.rubyonrails.org/security.html +# +class RecordsController < ApplicationController + before_action :set_record, only: [:show, :edit, :update, :destroy, :history, :state_at, :proof, :diff] + before_action :refresh_store + before_action :log_access, only: [:show, :proof] + + # GET /records + # List all audited records + def index + @page = (params[:page] || 1).to_i + @records = AuditedRecord.all(page: @page, per_page: 25) + @types = AuditedRecord.types + end + + # GET /records/search + # Search records + def search + @query = params[:q].to_s.strip + @records = @query.present? ? AuditedRecord.search(@query) : [] + end + + # GET /records/types + # List available record types + def types + @types = AuditedRecord.types + end + + # GET /records/:composite_id + # Show a specific record + def show + respond_to do |format| + format.html + format.json { render json: @record.to_hash } + end + end + + # GET /records/new + # Form for new record + def new + @record = AuditedRecord.new( + record_type: params[:type] || 'document', + record_id: params[:id] + ) + end + + # GET /records/:composite_id/edit + # Edit form + def edit + end + + # POST /records + # Create a new audited record + def create + @record = AuditedRecord.new(record_params) + @record.data = parse_data(params[:record][:data_text]) + + if @record.save(author: current_user, message: params[:commit_message]) + redirect_to record_path(@record.composite_id), notice: 'Record was successfully created.' + else + render :new, status: :unprocessable_entity + end + end + + # PATCH/PUT /records/:composite_id + # Update a record + def update + @record.data = parse_data(params[:record][:data_text]) + + if @record.save(author: current_user, message: params[:commit_message]) + redirect_to record_path(@record.composite_id), notice: 'Record was successfully updated.' + else + render :edit, status: :unprocessable_entity + end + end + + # DELETE /records/:composite_id + # Note: In audit systems, deletion might be prohibited for compliance + def destroy + # For audit systems, we typically don't allow hard deletes + # Instead, we might mark as deleted + @record.data = @record.data.merge('_deleted' => true, '_deleted_at' => Time.current.iso8601) + @record.save(author: current_user, message: "Marked as deleted: #{@record.composite_id}") + + redirect_to records_path, notice: 'Record was marked as deleted.' + end + + # GET /records/:composite_id/history + # Full revision history + def history + @revisions = @record.revisions(limit: 100) + end + + # GET /records/:composite_id/state_at?timestamp=2024-01-15T10:00:00Z + # Get record state at a point in time + def state_at + timestamp = params[:timestamp] ? Time.parse(params[:timestamp]) : 1.day.ago + @historical_record = @record.state_at(timestamp) + @timestamp = timestamp + end + + # GET /records/:composite_id/proof + # Generate existence proof for compliance + def proof + @proof = @record.existence_proof + + respond_to do |format| + format.html + format.json { render json: @proof } + end + end + + # GET /records/:composite_id/diff?commit_id=abc123 + # Show diff between versions + def diff + @commit_id = params[:commit_id] + @diff = @record.diff_with(@commit_id) if @commit_id + end + + private + + def set_record + @record = AuditedRecord.find_by_composite_id(params[:composite_id]) + redirect_to records_path, alert: 'Record not found.' unless @record + end + + def refresh_store + Rails.application.config.audit_store.refresh! + end + + def record_params + params.require(:record).permit(:record_type, :record_id) + end + + def parse_data(text) + return {} if text.blank? + YAML.safe_load(text, permitted_classes: [Date, Time]) + rescue Psych::SyntaxError => e + flash.now[:alert] = "Invalid YAML: #{e.message}" + {} + end + + def log_access + return unless @record + + AuditService.log_access( + record_type: @record.record_type, + record_id: @record.record_id, + user: current_user, + action: action_name + ) + end + + def current_user + @current_user ||= OpenStruct.new( + name: session[:user_name] || 'Auditor', + email: session[:user_email] || 'auditor@example.com' + ) + end + helper_method :current_user +end diff --git a/examples/audit_trail/app/models/audited_record.rb b/examples/audit_trail/app/models/audited_record.rb new file mode 100644 index 0000000..d9f3b20 --- /dev/null +++ b/examples/audit_trail/app/models/audited_record.rb @@ -0,0 +1,439 @@ +# frozen_string_literal: true + +require 'git_store' +require 'digest' + +# AuditedRecord provides tamper-evident, versioned storage for audit-critical data. +# Every change creates an immutable Git commit with cryptographic proof of existence. +# +# @example Creating an audited record +# AuditedRecord.create( +# record_type: 'transaction', +# record_id: 'txn_123', +# data: { amount: 100 }, +# author: user, +# message: 'Created transaction' +# ) +# +# @example Finding and verifying a record +# record = AuditedRecord.find('transaction', 'txn_123') +# record.verify_integrity! # => true +# +class AuditedRecord + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :record_type, :string + attribute :record_id, :string + attribute :data + attribute :created_at, :datetime + attribute :updated_at, :datetime + attribute :author_name, :string + attribute :author_email, :string + attribute :commit_id, :string + attribute :checksum, :string + + validates :record_type, presence: true, format: { with: /\A[a-z_]+\z/, + message: 'must be lowercase with underscores only' } + validates :record_id, presence: true + validates :data, presence: true + + class << self + # Create a new audited record + # + # @param record_type [String] The type of record (e.g., 'transaction') + # @param record_id [String] Unique identifier within the type + # @param data [Hash] The record data + # @param author [User, nil] The author creating the record + # @param message [String, nil] The audit message + # @return [AuditedRecord] The created record + def create(record_type:, record_id:, data:, author: nil, message: nil) + record = new( + record_type: record_type, + record_id: record_id, + data: data + ) + record.save(author: author, message: message) + record + end + + # Find a record by type and id + # + # @param record_type [String] The record type + # @param record_id [String] The record id + # @return [AuditedRecord, nil] The record or nil + def find(record_type, record_id) + path = storage_path(record_type, record_id) + stored = store[path] + return nil unless stored + + new( + record_type: record_type, + record_id: record_id, + data: stored['data'], + created_at: stored['created_at'] ? Time.parse(stored['created_at']) : nil, + updated_at: stored['updated_at'] ? Time.parse(stored['updated_at']) : nil, + author_name: stored['author_name'], + author_email: stored['author_email'], + checksum: stored['checksum'] + ) + end + + # Find a record, raising if not found + # + # @param record_type [String] The record type + # @param record_id [String] The record id + # @return [AuditedRecord] The record + # @raise [RecordNotFound] If not found + def find!(record_type, record_id) + find(record_type, record_id) or raise RecordNotFound, + "Record '#{record_type}/#{record_id}' not found" + end + + # Find by composite id (type/id) + # + # @param composite_id [String] Format: "type/id" + # @return [AuditedRecord, nil] The record + def find_by_composite_id(composite_id) + parts = composite_id.split('/', 2) + return nil unless parts.size == 2 + find(parts[0], parts[1]) + end + + # List all records of a specific type + # + # @param record_type [String] The record type + # @return [Array] All records of that type + def where(record_type:) + type_tree = store["records/#{record_type}"] + return [] unless type_tree.is_a?(GitStore::Tree) + + type_tree.table.keys.map do |filename| + next unless filename.end_with?('.yml') + record_id = filename.sub('.yml', '') + find(record_type, record_id) + end.compact + end + + # List all record types + # + # @return [Array] Available record types + def types + records_tree = store['records'] + return [] unless records_tree.is_a?(GitStore::Tree) + + records_tree.table.keys.select do |name| + records_tree.table[name].is_a?(GitStore::Tree) + end.sort + end + + # Search records across all types + # + # @param query [String] Search query + # @return [Array] Matching records + def search(query) + results = [] + types.each do |type| + where(record_type: type).each do |record| + if matches_query?(record, query) + results << record + end + end + end + results + end + + # Get all records (with pagination) + # + # @param page [Integer] Page number + # @param per_page [Integer] Records per page + # @return [Array] Records for the page + def all(page: 1, per_page: 50) + all_records = [] + types.each do |type| + all_records.concat(where(record_type: type)) + end + all_records.sort_by { |r| r.updated_at || Time.at(0) } + .reverse + .slice((page - 1) * per_page, per_page) || [] + end + + # Access the GitStore instance + def store + Rails.application.config.audit_store + end + + # Generate the storage path for a record + def storage_path(record_type, record_id) + "records/#{record_type}/#{record_id}.yml" + end + + private + + def matches_query?(record, query) + query_down = query.downcase + record.record_id.downcase.include?(query_down) || + record.data.to_s.downcase.include?(query_down) + end + end + + # Check if this is a new record + def new_record? + !self.class.find(record_type, record_id) + end + + # Check if persisted + def persisted? + !new_record? + end + + # Save the record with audit trail + # + # @param author [User, nil] The author + # @param message [String, nil] The audit message + # @return [Boolean] True if saved + def save(author: nil, message: nil) + return false unless valid? + + now = Time.current + self.updated_at = now + self.created_at ||= now + + self.author_name = author&.respond_to?(:name) ? author.name : 'System' + self.author_email = author&.respond_to?(:email) ? author.email : 'system@example.com' + + # Calculate checksum for integrity verification + self.checksum = calculate_checksum + + msg = message || (new_record? ? "Created #{record_type}/#{record_id}" : "Updated #{record_type}/#{record_id}") + + store.transaction(msg, git_author(author)) do + store[storage_path] = to_storage_hash + end + + # Store the commit ID + self.commit_id = store.head&.id + + true + rescue StandardError => e + errors.add(:base, e.message) + false + end + + # Get the complete revision history + # + # @param limit [Integer] Maximum revisions to return + # @return [Array] The revision history + def revisions(limit: 100) + commits = [] + current = store.head + + while current && commits.size < limit + if commit_affects_record?(current) + commits << Revision.new( + commit_id: current.id, + message: current.message, + author: current.author&.name, + email: current.author&.email, + timestamp: current.author&.time, + record: version_at(current.id) + ) + end + parent_id = current.parent.first + current = parent_id ? store.get(parent_id) : nil + end + + commits + end + + # Get the record state at a specific commit + # + # @param commit_id [String] The commit SHA + # @return [AuditedRecord, nil] The record at that commit + def version_at(commit_id) + commit = store.get(commit_id) + return nil unless commit + + stored = commit.tree[storage_path] + return nil unless stored + + self.class.new( + record_type: record_type, + record_id: record_id, + data: stored['data'], + created_at: stored['created_at'] ? Time.parse(stored['created_at']) : nil, + updated_at: stored['updated_at'] ? Time.parse(stored['updated_at']) : nil, + checksum: stored['checksum'] + ) + end + + # Get the record state at a specific point in time + # + # @param timestamp [Time] The point in time + # @return [AuditedRecord, nil] The record state + def state_at(timestamp) + revisions.each do |revision| + if revision.timestamp && revision.timestamp <= timestamp + return revision.record + end + end + nil + end + + # Verify data integrity using checksum + # + # @return [Boolean] True if data hasn't been tampered with + def verify_integrity! + return false unless checksum.present? + calculate_checksum == checksum + end + + # Generate existence proof for compliance + # + # @return [Hash] Proof of existence + def existence_proof + current_commit = store.head + revision = revisions.find { |r| r.record&.record_id == record_id } + + { + record_type: record_type, + record_id: record_id, + commit_id: revision&.commit_id, + timestamp: revision&.timestamp&.iso8601, + author: revision&.author, + checksum: checksum, + current_head: current_commit&.id, + verified: verify_integrity!, + generated_at: Time.current.iso8601 + } + end + + # Compare with another version + # + # @param commit_id [String] The other commit SHA + # @return [Hash] Diff data + def diff_with(commit_id) + other = version_at(commit_id) + return nil unless other + + { + from: { + commit_id: commit_id, + data: other.data, + timestamp: other.updated_at + }, + to: { + commit_id: self.commit_id, + data: data, + timestamp: updated_at + }, + changes: compute_diff(other.data, data) + } + end + + # Get composite ID for routing + def composite_id + "#{record_type}/#{record_id}" + end + + def to_param + composite_id + end + + # Convert to hash for storage + def to_storage_hash + { + 'data' => data, + 'created_at' => created_at&.iso8601, + 'updated_at' => updated_at&.iso8601, + 'author_name' => author_name, + 'author_email' => author_email, + 'checksum' => checksum + } + end + + # Convert to hash for API responses + def to_hash + { + 'record_type' => record_type, + 'record_id' => record_id, + 'data' => data, + 'created_at' => created_at&.iso8601, + 'updated_at' => updated_at&.iso8601, + 'checksum' => checksum, + 'verified' => verify_integrity! + } + end + + private + + def store + self.class.store + end + + def storage_path + self.class.storage_path(record_type, record_id) + end + + def git_author(author) + name = author&.respond_to?(:name) ? author.name : (author_name || 'System') + email = author&.respond_to?(:email) ? author.email : (author_email || 'system@example.com') + GitStore::User.new(name, email, Time.now) + end + + def calculate_checksum + content = { + record_type: record_type, + record_id: record_id, + data: data, + created_at: created_at&.iso8601 + }.to_yaml + Digest::SHA256.hexdigest(content) + end + + def commit_affects_record?(commit) + return false unless commit.parent.any? + + parent = store.get(commit.parent.first) + return true unless parent + + current_blob = commit.tree[storage_path] + parent_blob = parent.tree[storage_path] + + current_blob != parent_blob + end + + def compute_diff(old_data, new_data) + changes = [] + + # Handle nil inputs + old_data = old_data || {} + new_data = new_data || {} + + # Ensure both are hashes before calling .keys + return changes unless old_data.respond_to?(:keys) && new_data.respond_to?(:keys) + + all_keys = (old_data.keys + new_data.keys).uniq + all_keys.each do |key| + old_val = old_data[key] + new_val = new_data[key] + + next if old_val == new_val + + if old_val.nil? + changes << { action: 'added', key: key, value: new_val } + elsif new_val.nil? + changes << { action: 'removed', key: key, value: old_val } + else + changes << { action: 'changed', key: key, from: old_val, to: new_val } + end + end + + changes + end + + # Revision struct for history + Revision = Struct.new(:commit_id, :message, :author, :email, :timestamp, :record, keyword_init: true) + + class RecordNotFound < StandardError; end +end diff --git a/examples/audit_trail/app/services/audit_service.rb b/examples/audit_trail/app/services/audit_service.rb new file mode 100644 index 0000000..538312a --- /dev/null +++ b/examples/audit_trail/app/services/audit_service.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +# AuditService provides utilities for generating audit reports and logs. +# Supports compliance reporting for SOX, HIPAA, GDPR requirements. +# +# @example Generate an audit report +# AuditService.generate_report( +# record_type: 'financial_transaction', +# from: 30.days.ago, +# to: Time.current +# ) +# +class AuditService + class << self + # Log an access event + # + # @param record_type [String] The type of record accessed + # @param record_id [String] The record ID + # @param user [User] The user who accessed + # @param action [String] The action (view, export, etc.) + # @return [AuditedRecord] The access log entry + def log_access(record_type:, record_id:, user:, action:) + AuditedRecord.create( + record_type: 'access_log', + record_id: SecureRandom.uuid, + data: { + accessed_record_type: record_type, + accessed_record_id: record_id, + user_name: user.respond_to?(:name) ? user.name : user.to_s, + user_email: user.respond_to?(:email) ? user.email : nil, + action: action, + timestamp: Time.current.iso8601, + ip_address: Thread.current[:client_ip] + }, + author: user, + message: "Access log: #{action} #{record_type}/#{record_id}" + ) + end + + # Generate audit report for a time range + # + # @param record_type [String, nil] Filter by record type + # @param record_id [String, nil] Filter by record ID + # @param from [Time] Start of time range + # @param to [Time] End of time range + # @param format [Symbol] Output format (:hash, :csv, :json) + # @return [Array, String] The audit report + def generate_report(record_type: nil, record_id: nil, from: 30.days.ago, to: Time.current, format: :hash) + entries = collect_audit_entries( + record_type: record_type, + record_id: record_id, + from: from, + to: to + ) + + case format + when :csv + format_as_csv(entries) + when :json + entries.to_json + else + entries + end + end + + # Generate compliance summary + # + # @param from [Time] Start of time range + # @param to [Time] End of time range + # @return [Hash] Compliance summary + def compliance_summary(from: 30.days.ago, to: Time.current) + entries = collect_audit_entries(from: from, to: to) + + { + period: { + from: from.iso8601, + to: to.iso8601 + }, + total_changes: entries.size, + changes_by_type: entries.group_by { |e| e[:record_type] } + .transform_values(&:count), + changes_by_user: entries.group_by { |e| e[:author] } + .transform_values(&:count), + integrity_status: 'verified', + generated_at: Time.current.iso8601 + } + end + + # Verify integrity of all records + # + # @return [Hash] Integrity verification results + def verify_all_integrity + results = { verified: 0, failed: 0, errors: [] } + + AuditedRecord.types.each do |type| + AuditedRecord.where(record_type: type).each do |record| + if record.verify_integrity! + results[:verified] += 1 + else + results[:failed] += 1 + results[:errors] << { + record: "#{record.record_type}/#{record.record_id}", + error: 'Checksum mismatch' + } + end + end + end + + results[:status] = results[:failed].zero? ? 'healthy' : 'compromised' + results + end + + # Export audit log for compliance + # + # @param record_type [String, nil] Filter by type + # @param from [Time] Start date + # @param to [Time] End date + # @return [String] CSV export + def export_for_compliance(record_type: nil, from: 90.days.ago, to: Time.current) + entries = collect_audit_entries( + record_type: record_type, + from: from, + to: to + ) + + headers = ['Timestamp', 'Record Type', 'Record ID', 'Action', 'Author', 'Email', 'Commit ID', 'Message'] + + csv_data = CSV.generate do |csv| + csv << headers + entries.each do |entry| + csv << [ + entry[:timestamp], + entry[:record_type], + entry[:record_id], + entry[:action], + entry[:author], + entry[:email], + entry[:commit_id], + entry[:message] + ] + end + end + + csv_data + end + + private + + def collect_audit_entries(record_type: nil, record_id: nil, from: nil, to: nil) + entries = [] + store = Rails.application.config.audit_store + current = store.head + + while current + timestamp = current.author&.time + + # Filter by time range + if timestamp + break if from && timestamp < from + next if to && timestamp > to + end + + # Extract record info from commit + entry = extract_entry_from_commit(current, record_type, record_id) + entries << entry if entry + + parent_id = current.parent.first + current = parent_id ? store.get(parent_id) : nil + end + + entries + end + + def extract_entry_from_commit(commit, filter_type, filter_id) + # Parse the commit message to determine what changed + message = commit.message.to_s + author = commit.author + + # Try to extract record type and ID from message + if message =~ /(?:Created|Updated|Deleted) (\w+)\/(.+)/ + type = ::Regexp.last_match(1) + id = ::Regexp.last_match(2) + + # Apply filters + return nil if filter_type && type != filter_type + return nil if filter_id && id != filter_id + + action = if message.start_with?('Created') + 'create' + elsif message.start_with?('Updated') + 'update' + elsif message.start_with?('Deleted') + 'delete' + else + 'change' + end + + { + timestamp: author&.time&.iso8601, + record_type: type, + record_id: id, + action: action, + author: author&.name, + email: author&.email, + commit_id: commit.id, + message: message.strip + } + end + end + + def format_as_csv(entries) + require 'csv' + + CSV.generate do |csv| + csv << ['Timestamp', 'Type', 'ID', 'Action', 'Author', 'Commit', 'Message'] + entries.each do |entry| + csv << [ + entry[:timestamp], + entry[:record_type], + entry[:record_id], + entry[:action], + entry[:author], + entry[:commit_id]&.first(7), + entry[:message] + ] + end + end + end + end +end diff --git a/examples/audit_trail/app/views/records/index.html.erb b/examples/audit_trail/app/views/records/index.html.erb new file mode 100644 index 0000000..b146f6a --- /dev/null +++ b/examples/audit_trail/app/views/records/index.html.erb @@ -0,0 +1,91 @@ +
+
+

Audit Trail

+
+ <%= link_to 'Types', types_records_path, class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %> + <%= link_to 'Audit Reports', audit_logs_path, class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %> + <%= link_to 'New Record', new_record_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %> +
+
+ +
+ <%= form_with url: search_records_path, method: :get, class: 'flex gap-2 flex-1' do %> + <%= text_field_tag :q, params[:q], placeholder: 'Search records...', class: 'flex-1 px-4 py-2 border rounded' %> + <%= submit_tag 'Search', class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded cursor-pointer' %> + <% end %> +
+ + <% if @types.present? %> +
+ Filter by type: + <% @types.each do |type| %> + <%= link_to type, records_path(type: type), class: 'bg-gray-200 hover:bg-gray-300 px-3 py-1 rounded text-sm' %> + <% end %> +
+ <% end %> + +
+ + + + + + + + + + + + <% if @records.empty? %> + + + + <% else %> + <% @records.each do |record| %> + + + + + + + + <% end %> + <% end %> + +
TypeIDLast ModifiedIntegrityActions
+ No records found. <%= link_to 'Create the first record', new_record_path, class: 'text-blue-500 hover:underline' %> +
+ + <%= record.record_type %> + + + <%= link_to record.record_id, record_path(record.composite_id), class: 'text-blue-600 hover:text-blue-800 font-mono text-sm' %> + + <% if record.updated_at %> + <%= record.updated_at.strftime('%Y-%m-%d %H:%M:%S') %> + <% end %> + + <% if record.verify_integrity! %> + ✓ Verified + <% else %> + ✗ Failed + <% end %> + +
+ <%= link_to 'View', record_path(record.composite_id), class: 'text-blue-600 hover:text-blue-800' %> + <%= link_to 'History', history_record_path(record.composite_id), class: 'text-gray-600 hover:text-gray-800' %> + <%= link_to 'Proof', proof_record_path(record.composite_id), class: 'text-green-600 hover:text-green-800' %> +
+
+
+ + <% if @records.size >= 25 %> +
+ <% if @page > 1 %> + <%= link_to '← Previous', records_path(page: @page - 1), class: 'px-4 py-2 bg-gray-200 rounded hover:bg-gray-300' %> + <% end %> + Page <%= @page %> + <%= link_to 'Next →', records_path(page: @page + 1), class: 'px-4 py-2 bg-gray-200 rounded hover:bg-gray-300' %> +
+ <% end %> +
diff --git a/examples/audit_trail/app/views/records/proof.html.erb b/examples/audit_trail/app/views/records/proof.html.erb new file mode 100644 index 0000000..1cac293 --- /dev/null +++ b/examples/audit_trail/app/views/records/proof.html.erb @@ -0,0 +1,107 @@ +
+ + +
+
+

+ 🔒 Existence Proof +

+

Cryptographic proof that this record exists and hasn't been tampered with

+
+ +
+
+

Proof Details

+ +
+
+
Record Type
+
<%= @proof[:record_type] %>
+
+ +
+
Record ID
+
<%= @proof[:record_id] %>
+
+ +
+
Commit ID
+
<%= @proof[:commit_id] %>
+
+ +
+
Timestamp
+
<%= @proof[:timestamp] %>
+
+ +
+
Author
+
<%= @proof[:author] %>
+
+ +
+
Data Checksum (SHA-256)
+
<%= @proof[:checksum] %>
+
+ +
+
Current HEAD
+
<%= @proof[:current_head] %>
+
+ +
+
Integrity Status
+
+ <% if @proof[:verified] %> + + ✓ Verified - Data integrity confirmed + + <% else %> + + ✗ Failed - Data may have been tampered with + + <% end %> +
+
+ +
+
Proof Generated At
+
<%= @proof[:generated_at] %>
+
+
+
+ +
+

What This Proves

+
    +
  • + + The record existed at the timestamp shown +
  • +
  • + + The data has not been modified since that time (verified via SHA-256 checksum) +
  • +
  • + + The change was made by the author shown +
  • +
  • + + The commit ID can be independently verified using Git +
  • +
+
+
+ +
+ <%= link_to '← Back to Record', record_path(@record.composite_id), class: 'text-blue-600 hover:text-blue-800' %> + <%= link_to 'Download Proof (JSON)', proof_record_path(@record.composite_id, format: :json), class: 'bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded' %> +
+
+
diff --git a/examples/audit_trail/app/views/records/show.html.erb b/examples/audit_trail/app/views/records/show.html.erb new file mode 100644 index 0000000..97b855a --- /dev/null +++ b/examples/audit_trail/app/views/records/show.html.erb @@ -0,0 +1,71 @@ +
+ + +
+
+
+
+ + <%= h @record.record_type %> + +

<%= h @record.record_id %>

+
+
+ <%= link_to 'Edit', edit_record_path(@record.composite_id), class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %> + <%= link_to 'History', history_record_path(@record.composite_id), class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %> + <%= link_to 'Proof', proof_record_path(@record.composite_id), class: 'bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded' %> +
+
+
+ +
+
+
+

Created

+

<%= @record.created_at&.strftime('%Y-%m-%d %H:%M:%S %Z') || 'Unknown' %>

+
+
+

Last Modified

+

<%= @record.updated_at&.strftime('%Y-%m-%d %H:%M:%S %Z') || 'Unknown' %>

+
+
+

Last Author

+

<%= h @record.author_name %> (<%= h @record.author_email %>)

+
+
+

Integrity

+

+ <% if @record.verify_integrity! %> + ✓ Verified + <% else %> + ✗ Integrity Check Failed + <% end %> +

+
+
+ +
+

Checksum (SHA-256)

+ + <%= h @record.checksum %> + +
+ +
+

Record Data

+
<%= h @record.data.to_yaml %>
+
+
+ +
+
+ <%= link_to 'View as JSON', record_path(@record.composite_id, format: :json), class: 'text-blue-600 hover:text-blue-800' %> + <%= link_to 'Point-in-Time Query', state_at_record_path(@record.composite_id, timestamp: 1.day.ago.iso8601), class: 'text-blue-600 hover:text-blue-800' %> +
+
+
+
diff --git a/examples/audit_trail/config/initializers/git_store.rb b/examples/audit_trail/config/initializers/git_store.rb new file mode 100644 index 0000000..7de16bf --- /dev/null +++ b/examples/audit_trail/config/initializers/git_store.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# GitStore configuration for Rails 8 - Audit Trail +# Provides tamper-evident, versioned storage for compliance requirements + +Rails.application.config.to_prepare do + # Path to the audit repository + audit_repo_path = Rails.root.join('audit_repo') + + # Ensure the repository exists + unless File.exist?(audit_repo_path.join('.git')) + FileUtils.mkdir_p(audit_repo_path) + Dir.chdir(audit_repo_path) do + # Use array form to avoid shell injection + system('git', 'init') + system('git', 'config', 'user.name', 'Audit System') + system('git', 'config', 'user.email', 'audit@example.com') + end + end + + # Initialize the GitStore instance for audit trail + Rails.application.config.audit_store = GitStore.new(audit_repo_path.to_s) +end + +# Helper module to access the audit store +module AuditStoreHelper + def audit_store + Rails.application.config.audit_store + end +end diff --git a/examples/audit_trail/config/routes.rb b/examples/audit_trail/config/routes.rb new file mode 100644 index 0000000..23fcbb6 --- /dev/null +++ b/examples/audit_trail/config/routes.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + # Audited records + resources :records, param: :composite_id, constraints: { composite_id: /[^\/]+\/[^\/]+/ } do + member do + get :history + get :state_at + get :proof + get :diff + end + collection do + get :search + get :types + end + end + + # Audit logs and reports + resources :audit_logs, only: [:index, :show] do + collection do + get :report + get :export + end + end + + # API namespace + namespace :api do + namespace :v1 do + resources :records, param: :composite_id, constraints: { composite_id: /[^\/]+\/[^\/]+/ } + end + end + + root 'records#index' +end diff --git a/examples/config_management/README.md b/examples/config_management/README.md new file mode 100644 index 0000000..6a4dc3a --- /dev/null +++ b/examples/config_management/README.md @@ -0,0 +1,136 @@ +# GitStore Configuration Management Example + +A Rails 8 application for versioned configuration management powered by GitStore. + +## Features + +- **Version-controlled configs** - Every config change is a Git commit +- **Instant rollback** - Revert bad configurations immediately +- **Audit trail** - Track who changed what and when +- **PR-based workflow** - Review config changes before deployment +- **Environment support** - Manage configs per environment + +## Quick Start + +```bash +# Create a new Rails 8 application +rails new config_manager --database=sqlite3 --css=tailwind + +# Add git_store to Gemfile +echo "gem 'git_store', path: '../../../'" >> Gemfile + +# Install dependencies +bundle install + +# Copy the example files +cp -r app/* config_manager/app/ +cp -r config/* config_manager/config/ + +# Initialize the configuration repository +mkdir -p config_manager/config_repo +cd config_manager/config_repo && git init + +# Run the application +cd config_manager && rails server +``` + +## Directory Structure + +``` +config_management/ +├── app/ +│ ├── controllers/ +│ │ └── configs_controller.rb # Config CRUD +│ ├── models/ +│ │ └── config_entry.rb # Config model +│ └── views/ +│ └── configs/ # ERB templates +├── config/ +│ ├── routes.rb +│ └── initializers/ +│ └── git_store.rb +└── README.md +``` + +## Usage Examples + +### Managing Application Settings + +```ruby +# Store a feature flag configuration +ConfigEntry.set( + 'features/dark_mode.yml', + { + enabled: true, + rollout_percentage: 50, + allowed_users: ['admin@example.com'] + }, + author: current_user, + message: 'Enable dark mode for 50% of users' +) + +# Read the configuration +config = ConfigEntry.get('features/dark_mode.yml') +# => { enabled: true, rollout_percentage: 50, ... } +``` + +### Environment-Specific Configs + +```ruby +# Set production database config +ConfigEntry.set( + 'environments/production/database.yml', + { + adapter: 'postgresql', + pool: 25, + timeout: 5000 + }, + author: current_user, + message: 'Increase connection pool for production' +) + +# Get all configs for an environment +ConfigEntry.for_environment('production') +``` + +### Rolling Back Bad Configs + +```ruby +# Oops! The new config broke something +config = ConfigEntry.find('features/dark_mode.yml') +config.rollback_to( + commit_id: 'abc123', + author: current_user, + message: 'Rollback: Dark mode causing crashes' +) +``` + +### Comparing Configurations + +```ruby +# See what changed between versions +config = ConfigEntry.find('features/dark_mode.yml') +diff = config.diff_with('abc123') +puts diff +# -rollout_percentage: 50 +# +rollout_percentage: 100 +``` + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | /configs | List all configs | +| GET | /configs/:path | Get config value | +| POST | /configs | Create config | +| PATCH | /configs/:path | Update config | +| DELETE | /configs/:path | Delete config | +| GET | /configs/:path/history | Version history | +| POST | /configs/:path/rollback | Rollback to version | + +## Security Considerations + +1. **Access Control** - Implement authentication/authorization for config changes +2. **Sensitive Data** - Use Rails credentials for secrets, not this system +3. **Validation** - Validate configs before saving to prevent invalid states +4. **Audit Logging** - All changes are tracked in Git history diff --git a/examples/config_management/app/controllers/configs_controller.rb b/examples/config_management/app/controllers/configs_controller.rb new file mode 100644 index 0000000..6ccd845 --- /dev/null +++ b/examples/config_management/app/controllers/configs_controller.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +# Controller for managing versioned application configurations. +# Provides CRUD operations with full version history and rollback support. +# +# NOTE: This controller expects to inherit from ApplicationController which should +# have CSRF protection enabled (protect_from_forgery with: :exception). +# See Rails security guide: https://guides.rubyonrails.org/security.html +# +class ConfigsController < ApplicationController + before_action :set_config, only: [:show, :edit, :update, :destroy, :history, :rollback, :diff] + before_action :refresh_store + + # GET /configs + # List all configurations grouped by directory + def index + @configs = if params[:q].present? + ConfigEntry.search(params[:q]) + else + ConfigEntry.all + end + @grouped_configs = @configs.group_by { |c| File.dirname(c.path) } + end + + # GET /configs/environments + # List available environments + def environments + @environments = ConfigEntry.environments + end + + # GET /configs/environment/:env + # Show configs for a specific environment + def environment + @environment = params[:env] + @configs = ConfigEntry.for_environment(@environment) + end + + # GET /configs/:path + # Show a specific configuration + def show + respond_to do |format| + format.html + format.json { render json: @config.to_hash } + format.yaml { render plain: @config.value.to_yaml } + end + end + + # GET /configs/new + # Form to create a new configuration + def new + @config = ConfigEntry.new(path: params[:path]) + end + + # GET /configs/:path/edit + # Form to edit a configuration + def edit + end + + # POST /configs + # Create a new configuration + def create + @config = ConfigEntry.new(config_params) + @config.value = parse_value(params[:config][:value_text], @config.path) + + if @config.save(author: current_user, message: params[:commit_message]) + redirect_to config_path(@config.path), notice: 'Configuration was successfully created.' + else + render :new, status: :unprocessable_entity + end + end + + # PATCH/PUT /configs/:path + # Update a configuration + def update + @config.value = parse_value(params[:config][:value_text], @config.path) + + if @config.save(author: current_user, message: params[:commit_message]) + redirect_to config_path(@config.path), notice: 'Configuration was successfully updated.' + else + render :edit, status: :unprocessable_entity + end + end + + # DELETE /configs/:path + # Delete a configuration + def destroy + @config.destroy(author: current_user, message: params[:commit_message]) + redirect_to configs_path, notice: 'Configuration was successfully deleted.' + end + + # GET /configs/:path/history + # Show version history + def history + @commits = @config.history(limit: 50) + end + + # POST /configs/:path/rollback + # Rollback to a previous version + def rollback + commit_id = params[:commit_id] + + if @config.rollback_to(commit_id: commit_id, author: current_user) + redirect_to config_path(@config.path), notice: "Successfully rolled back to version #{commit_id[0..7]}." + else + redirect_to history_config_path(@config.path), alert: 'Failed to rollback.' + end + end + + # GET /configs/:path/diff + # Show diff between versions + def diff + @from_commit = params[:from] + @to_commit = params[:to] || store.head&.id + + if @from_commit && @to_commit + @diff = @config.diff_with(@from_commit) + @from_version = @config.version_at(@from_commit) + @to_version = @config.version_at(@to_commit) + else + redirect_to history_config_path(@config.path), alert: 'Please select two versions to compare.' + end + end + + private + + def set_config + @config = ConfigEntry.find!(params[:path]) + rescue ConfigEntry::RecordNotFound + redirect_to new_config_path(path: params[:path]) + end + + def refresh_store + store.refresh! + end + + def config_params + params.require(:config).permit(:path) + end + + def parse_value(text, path) + return {} if text.blank? + + ext = File.extname(path).delete('.') + case ext + when 'json' + JSON.parse(text) + when 'yml', 'yaml' + YAML.safe_load(text, permitted_classes: [Date, Time]) + else + text + end + rescue JSON::ParserError, Psych::SyntaxError => e + flash.now[:alert] = "Invalid #{ext.upcase}: #{e.message}" + nil + end + + def store + Rails.application.config.config_store + end + + def current_user + @current_user ||= OpenStruct.new( + name: session[:user_name] || 'Admin', + email: session[:user_email] || 'admin@example.com' + ) + end + helper_method :current_user +end diff --git a/examples/config_management/app/models/config_entry.rb b/examples/config_management/app/models/config_entry.rb new file mode 100644 index 0000000..3de25d4 --- /dev/null +++ b/examples/config_management/app/models/config_entry.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require 'git_store' +require 'json' + +# ConfigEntry model for managing versioned application configurations. +# Supports YAML and JSON formats with full version history. +# +# @example Setting a configuration +# ConfigEntry.set('features/beta.yml', { enabled: true }, author: user) +# +# @example Getting a configuration +# config = ConfigEntry.get('features/beta.yml') +# # => { 'enabled' => true } +# +class ConfigEntry + include ActiveModel::Model + include ActiveModel::Attributes + + # Supported file formats + SUPPORTED_FORMATS = %w[yml yaml json].freeze + + attribute :path, :string + attribute :value + attribute :format, :string + attribute :updated_at, :datetime + attribute :author_name, :string + attribute :author_email, :string + + validates :path, presence: true + validates :value, presence: true + validate :validate_format + + class << self + # Get a configuration value by path + # + # @param path [String] The config path (e.g., 'features/beta.yml') + # @return [Hash, Array, String, nil] The configuration value + def get(path) + store[path] + end + + # Set a configuration value + # + # @param path [String] The config path + # @param value [Hash, Array, String] The configuration value + # @param author [User, nil] The author making the change + # @param message [String, nil] The commit message + # @return [Boolean] True if saved successfully + def set(path, value, author: nil, message: nil) + entry = new(path: path, value: value) + entry.save(author: author, message: message) + end + + # Find a config entry by path + # + # @param path [String] The config path + # @return [ConfigEntry, nil] The entry or nil + def find(path) + value = store[path] + return nil unless value + + new( + path: path, + value: value, + format: File.extname(path).delete('.'), + updated_at: Time.current + ) + end + + # Find a config entry, raising if not found + # + # @param path [String] The config path + # @return [ConfigEntry] The entry + # @raise [RecordNotFound] If not found + def find!(path) + find(path) or raise RecordNotFound, "Config '#{path}' not found" + end + + # List all configuration files + # + # @return [Array] All configs + def all + entries = [] + collect_entries(store.root, '', entries) + entries.sort_by(&:path) + end + + # Get configs for a specific environment + # + # @param env [String] Environment name (e.g., 'production') + # @return [Array] Configs for that environment + def for_environment(env) + all.select { |entry| entry.path.start_with?("environments/#{env}/") } + end + + # List available environments + # + # @return [Array] Environment names + def environments + all.map { |entry| entry.path.split('/')[1] if entry.path.start_with?('environments/') } + .compact + .uniq + .sort + end + + # Search configs by path pattern + # + # @param pattern [String] Search pattern + # @return [Array] Matching configs + def search(pattern) + pattern_downcase = pattern.downcase + all.select { |entry| entry.path.downcase.include?(pattern_downcase) } + end + + # Delete a configuration + # + # @param path [String] The config path + # @param author [User, nil] The author + # @param message [String, nil] The commit message + # @return [Boolean] True if deleted + def delete(path, author: nil, message: nil) + msg = message || "Deleted config: #{path}" + store.transaction(msg, git_author(author)) do + store.delete(path) + end + true + rescue StandardError + false + end + + # Access the GitStore instance + def store + Rails.application.config.config_store + end + + private + + def collect_entries(tree, prefix, entries) + return unless tree.is_a?(GitStore::Tree) + + tree.table.each do |name, entry| + full_path = prefix.empty? ? name : "#{prefix}/#{name}" + + if entry.is_a?(GitStore::Tree) + collect_entries(entry, full_path, entries) + elsif SUPPORTED_FORMATS.include?(File.extname(name).delete('.')) + value = tree[name] + entries << new( + path: full_path, + value: value, + format: File.extname(name).delete('.') + ) + end + end + end + + def git_author(author) + name = author&.respond_to?(:name) ? author.name : 'System' + email = author&.respond_to?(:email) ? author.email : 'system@example.com' + GitStore::User.new(name, email, Time.now) + end + end + + # Check if this is a new record + def new_record? + !self.class.find(path) + end + + # Check if persisted + def persisted? + !new_record? + end + + # Save the configuration + # + # @param author [User, nil] The author + # @param message [String, nil] The commit message + # @return [Boolean] True if saved + def save(author: nil, message: nil) + return false unless valid? + + self.updated_at = Time.current + self.author_name = author&.respond_to?(:name) ? author.name : 'System' + self.author_email = author&.respond_to?(:email) ? author.email : 'system@example.com' + + msg = message || (new_record? ? "Created config: #{path}" : "Updated config: #{path}") + + store.transaction(msg, git_author(author)) do + store[path] = value + end + + true + rescue StandardError => e + errors.add(:base, e.message) + false + end + + # Delete this configuration + # + # @param author [User, nil] The author + # @param message [String, nil] The commit message + # @return [Boolean] True if deleted + def destroy(author: nil, message: nil) + self.class.delete(path, author: author, message: message) + end + + # Get version history for this config + # + # @param limit [Integer] Maximum commits to return + # @return [Array] Commit history + def history(limit: 50) + commits = [] + current = store.head + + while current && commits.size < limit + if commit_affects_config?(current) + commits << current + end + parent_id = current.parent.first + current = parent_id ? store.get(parent_id) : nil + end + + commits + end + + # Get the config value at a specific commit + # + # @param commit_id [String] The commit SHA + # @return [ConfigEntry, nil] The config at that commit + def version_at(commit_id) + commit = store.get(commit_id) + return nil unless commit + + value = commit.tree[path] + return nil unless value + + self.class.new( + path: path, + value: value, + format: format + ) + end + + # Rollback to a previous version + # + # @param commit_id [String] The commit SHA + # @param author [User, nil] The author + # @param message [String, nil] The commit message + # @return [Boolean] True if successful + def rollback_to(commit_id:, author: nil, message: nil) + previous = version_at(commit_id) + return false unless previous + + self.value = previous.value + msg = message || "Rolled back config #{path} to version #{commit_id[0..7]}" + save(author: author, message: msg) + end + + # Get diff with another version + # + # @param other_commit_id [String] The other commit SHA + # @return [String] Diff output + def diff_with(other_commit_id) + store.head.diff(other_commit_id, path) + end + + # Convert to hash for API responses + # + # @return [Hash] The config data + def to_hash + { + 'path' => path, + 'value' => value, + 'format' => format, + 'updated_at' => updated_at&.iso8601 + } + end + + # For routing + def to_param + path + end + + private + + def store + self.class.store + end + + def git_author(author) + name = author&.respond_to?(:name) ? author.name : (author_name || 'System') + email = author&.respond_to?(:email) ? author.email : (author_email || 'system@example.com') + GitStore::User.new(name, email, Time.now) + end + + def validate_format + return if path.blank? + + ext = File.extname(path).delete('.') + unless SUPPORTED_FORMATS.include?(ext) + errors.add(:path, "must end with #{SUPPORTED_FORMATS.join(', ')}") + end + end + + def commit_affects_config?(commit) + return false unless commit.parent.any? + + parent = store.get(commit.parent.first) + return true unless parent + + current_blob = commit.tree[path] + parent_blob = parent.tree[path] + + current_blob != parent_blob + end + + class RecordNotFound < StandardError; end +end diff --git a/examples/config_management/app/views/configs/index.html.erb b/examples/config_management/app/views/configs/index.html.erb new file mode 100644 index 0000000..e1e783e --- /dev/null +++ b/examples/config_management/app/views/configs/index.html.erb @@ -0,0 +1,53 @@ +
+
+

Configuration Management

+
+ <%= link_to 'Environments', environments_configs_path, class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %> + <%= link_to 'New Config', new_config_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %> +
+
+ +
+ <%= form_with url: configs_path, method: :get, class: 'flex gap-2' do %> + <%= text_field_tag :q, params[:q], placeholder: 'Search configurations...', class: 'flex-1 px-4 py-2 border rounded' %> + <%= submit_tag 'Search', class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded cursor-pointer' %> + <% end %> +
+ + <% if @configs.empty? %> +
+

No configurations yet. <%= link_to 'Create the first one', new_config_path, class: 'text-blue-500 hover:underline' %>

+
+ <% else %> +
+ <% @grouped_configs.each do |directory, configs| %> +
+
+

+ 📁 <%= directory %> +

+
+
    + <% configs.each do |config| %> +
  • +
    + + <%= link_to File.basename(config.path), config_path(config.path), class: 'text-blue-600 hover:text-blue-800' %> + + + <%= config.format.upcase %> + +
    +
    + <%= link_to 'View', config_path(config.path), class: 'text-blue-600 hover:text-blue-800' %> + <%= link_to 'Edit', edit_config_path(config.path), class: 'text-green-600 hover:text-green-800' %> + <%= link_to 'History', history_config_path(config.path), class: 'text-gray-600 hover:text-gray-800' %> +
    +
  • + <% end %> +
+
+ <% end %> +
+ <% end %> +
diff --git a/examples/config_management/app/views/configs/show.html.erb b/examples/config_management/app/views/configs/show.html.erb new file mode 100644 index 0000000..052239b --- /dev/null +++ b/examples/config_management/app/views/configs/show.html.erb @@ -0,0 +1,33 @@ +
+ + +
+
+
+

<%= h @config.path %>

+ Format: <%= h @config.format.upcase %> +
+
+ <%= link_to 'Edit', edit_config_path(@config.path), class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %> + <%= link_to 'History', history_config_path(@config.path), class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %> +
+
+ +
+

Current Value

+
<%= h @config.value.to_yaml %>
+
+ +
+
+ <%= link_to 'Download YAML', config_path(@config.path, format: :yaml), class: 'text-blue-600 hover:text-blue-800' %> + <%= link_to 'Download JSON', config_path(@config.path, format: :json), class: 'text-blue-600 hover:text-blue-800' %> + <%= button_to 'Delete', config_path(@config.path), method: :delete, + class: 'text-red-600 hover:text-red-800', + data: { turbo_confirm: 'Are you sure you want to delete this configuration?' } %> +
+
+
+
diff --git a/examples/config_management/config/initializers/git_store.rb b/examples/config_management/config/initializers/git_store.rb new file mode 100644 index 0000000..02d6529 --- /dev/null +++ b/examples/config_management/config/initializers/git_store.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# GitStore configuration for Rails 8 - Configuration Management +# Stores application configurations in a versioned Git repository + +Rails.application.config.to_prepare do + # Path to the configuration repository + config_repo_path = Rails.root.join('config_repo') + + # Ensure the repository exists + unless File.exist?(config_repo_path.join('.git')) + FileUtils.mkdir_p(config_repo_path) + Dir.chdir(config_repo_path) do + # Use array form to avoid shell injection + system('git', 'init') + system('git', 'config', 'user.name', 'Config System') + system('git', 'config', 'user.email', 'config@example.com') + end + end + + # Initialize the GitStore instance for configs + Rails.application.config.config_store = GitStore.new(config_repo_path.to_s) +end + +# Helper module to access the config store +module ConfigStoreHelper + def config_store + Rails.application.config.config_store + end +end diff --git a/examples/config_management/config/routes.rb b/examples/config_management/config/routes.rb new file mode 100644 index 0000000..cd09779 --- /dev/null +++ b/examples/config_management/config/routes.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + # Configuration management routes + resources :configs, param: :path, path: 'configs', constraints: { path: /.*/ } do + member do + get :history + post :rollback + get :diff + end + collection do + get :environments + get 'environment/:env', action: :environment, as: :environment + end + end + + # API namespace for programmatic access + namespace :api do + namespace :v1 do + resources :configs, param: :path, constraints: { path: /.*/ }, only: [:index, :show, :create, :update, :destroy] + end + end + + root 'configs#index' +end diff --git a/examples/wiki_cms/README.md b/examples/wiki_cms/README.md new file mode 100644 index 0000000..77114da --- /dev/null +++ b/examples/wiki_cms/README.md @@ -0,0 +1,89 @@ +# GitStore Wiki/CMS Example + +A complete Rails 8 wiki application powered by GitStore for versioned content management. + +## Features + +- **Full version history** - Every edit creates a Git commit +- **Rollback support** - Revert to any previous version instantly +- **Diff viewing** - See exactly what changed between versions +- **Branch support** - Draft content in branches, merge when ready +- **Git-native storage** - Content can be edited via Git or the web UI + +## Quick Start + +```bash +# Create a new Rails 8 application +rails new wiki_app --database=sqlite3 --css=tailwind + +# Add git_store to Gemfile +echo "gem 'git_store', path: '../../../'" >> Gemfile + +# Install dependencies +bundle install + +# Copy the example files to your Rails app +cp -r app/* wiki_app/app/ +cp -r config/routes.rb wiki_app/config/ +cp -r config/initializers/* wiki_app/config/initializers/ + +# Initialize the content repository +mkdir -p wiki_app/content_repo +cd wiki_app/content_repo && git init + +# Run the application +cd wiki_app && rails server +``` + +## Directory Structure + +``` +wiki_cms/ +├── app/ +│ ├── controllers/ +│ │ └── pages_controller.rb # CRUD for wiki pages +│ ├── models/ +│ │ └── page.rb # Page model using GitStore +│ ├── views/ +│ │ └── pages/ # ERB templates +│ └── helpers/ +│ └── pages_helper.rb # View helpers +├── config/ +│ ├── routes.rb # Route configuration +│ └── initializers/ +│ └── git_store.rb # GitStore configuration +└── README.md +``` + +## Usage + +### Creating a page + +```ruby +# Via Rails console +page = Page.new( + slug: 'getting-started', + title: 'Getting Started Guide', + content: '# Welcome\n\nThis is your first wiki page.', + author: 'john@example.com' +) +page.save(message: 'Created getting started guide') +``` + +### Viewing history + +```ruby +# Get all versions of a page +page = Page.find('getting-started') +page.history.each do |commit| + puts "#{commit.author.name} - #{commit.message} - #{commit.author.time}" +end +``` + +### Rolling back + +```ruby +# Revert to a previous version +page = Page.find('getting-started') +page.rollback_to(commit_id: 'abc123', message: 'Reverting bad edit') +``` diff --git a/examples/wiki_cms/app/controllers/pages_controller.rb b/examples/wiki_cms/app/controllers/pages_controller.rb new file mode 100644 index 0000000..192c77d --- /dev/null +++ b/examples/wiki_cms/app/controllers/pages_controller.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +# Controller for wiki pages with full CRUD operations and version history. +# Provides a complete wiki experience with version control features. +# +# NOTE: This controller expects to inherit from ApplicationController which should +# have CSRF protection enabled (protect_from_forgery with: :exception). +# See Rails security guide: https://guides.rubyonrails.org/security.html +# +# @example Routes +# GET /pages # List all pages +# GET /pages/new # New page form +# POST /pages # Create page +# GET /pages/:slug # Show page +# GET /pages/:slug/edit # Edit form +# PATCH /pages/:slug # Update page +# DELETE /pages/:slug # Delete page +# GET /pages/:slug/history # Version history +# GET /pages/:slug/diff # Show diff +# POST /pages/:slug/rollback # Rollback to version +# +class PagesController < ApplicationController + before_action :set_page, only: [:show, :edit, :update, :destroy, :history, :diff, :rollback, :raw] + before_action :refresh_store + + # GET /pages + # List all wiki pages + def index + @pages = Page.all + end + + # GET /pages/recent + # Show recently updated pages + def recent + @pages = Page.recent(limit: 20) + render :index + end + + # GET /pages/search?q=query + # Search pages + def search + @query = params[:q].to_s.strip + @pages = @query.present? ? Page.search(@query) : Page.all + render :index + end + + # GET /pages/:slug + # Show a specific page + def show + respond_to do |format| + format.html + format.json { render json: @page.to_hash } + end + end + + # GET /pages/:slug/raw + # Show raw markdown content + def raw + render plain: @page.content, content_type: 'text/plain' + end + + # GET /pages/new + # New page form + def new + @page = Page.new(slug: params[:slug]) + end + + # GET /pages/:slug/edit + # Edit page form + def edit + end + + # POST /pages + # Create a new page + def create + @page = Page.new(page_params) + + if @page.save(author: current_user, message: params[:commit_message]) + redirect_to page_path(@page), notice: 'Page was successfully created.' + else + render :new, status: :unprocessable_entity + end + end + + # PATCH/PUT /pages/:slug + # Update a page + def update + @page.assign_attributes(page_params) + + if @page.save(author: current_user, message: params[:commit_message]) + redirect_to page_path(@page), notice: 'Page was successfully updated.' + else + render :edit, status: :unprocessable_entity + end + end + + # DELETE /pages/:slug + # Delete a page + def destroy + @page.destroy(author: current_user, message: params[:commit_message]) + redirect_to pages_path, notice: 'Page was successfully deleted.' + end + + # GET /pages/:slug/history + # Show version history + def history + @commits = @page.history(limit: 50) + end + + # GET /pages/:slug/diff?from=abc123&to=def456 + # Show diff between versions + def diff + @from_commit = params[:from] + @to_commit = params[:to] || store.head&.id + + if @from_commit && @to_commit + @diff = @page.diff_with(@from_commit) + @from_version = @page.version_at(@from_commit) + @to_version = @page.version_at(@to_commit) + else + redirect_to history_page_path(@page), alert: 'Please select two versions to compare.' + end + end + + # POST /pages/:slug/rollback + # Rollback to a previous version + def rollback + commit_id = params[:commit_id] + + if @page.rollback_to(commit_id: commit_id, author: current_user) + redirect_to page_path(@page), notice: "Successfully rolled back to version #{commit_id[0..7]}." + else + redirect_to history_page_path(@page), alert: 'Failed to rollback. The version may not exist.' + end + end + + private + + # Find the page by slug + def set_page + @page = Page.find!(params[:slug]) + rescue Page::RecordNotFound + if action_name == 'show' + redirect_to new_page_path(slug: params[:slug]) + else + redirect_to pages_path, alert: 'Page not found.' + end + end + + # Refresh the store to pick up external changes + def refresh_store + store.refresh! + end + + # Strong parameters for page + def page_params + params.require(:page).permit(:slug, :title, :content, tags: []) + end + + # Access the GitStore + def store + Rails.application.config.content_store + end + + # Helper for current user (implement based on your auth system) + def current_user + # Return current authenticated user + # Example with Devise: current_user + # Example with basic auth: request.env['REMOTE_USER'] + @current_user ||= OpenStruct.new( + name: session[:user_name] || 'Anonymous', + email: session[:user_email] || 'anonymous@example.com' + ) + end + helper_method :current_user +end diff --git a/examples/wiki_cms/app/helpers/pages_helper.rb b/examples/wiki_cms/app/helpers/pages_helper.rb new file mode 100644 index 0000000..49b201e --- /dev/null +++ b/examples/wiki_cms/app/helpers/pages_helper.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +# Helper methods for rendering wiki pages +module PagesHelper + # Render markdown content to HTML + # + # NOTE: This is a simplified markdown renderer for demonstration purposes. + # In production, use a proper markdown library like 'redcarpet' or 'kramdown' + # which handles security concerns properly. + # + # @param content [String] Markdown content + # @return [String] HTML content + def render_markdown(content) + return '' if content.blank? + + # First, HTML-escape all content to prevent XSS + html = ERB::Util.html_escape(content) + + # Headers - only allow text content (already escaped) + html.gsub!(/^### (.+)$/, '

\1

') + html.gsub!(/^## (.+)$/, '

\1

') + html.gsub!(/^# (.+)$/, '

\1

') + + # Bold and italic + html.gsub!(/\*\*(.+?)\*\*/, '\1') + html.gsub!(/\*(.+?)\*/, '\1') + + # Code blocks (content already escaped) + html.gsub!(/```(\w*)\n(.*?)```/m) do + lang = ::Regexp.last_match(1) + code = ::Regexp.last_match(2) + "
#{code}
" + end + + # Inline code (content already escaped) + html.gsub!(/`([^`]+)`/, '\1') + + # Links - URL is already escaped, validate it's not javascript: + html.gsub!(/\[([^\]]+)\]\(([^)]+)\)/) do + text = ::Regexp.last_match(1) + url = ::Regexp.last_match(2) + # Reject potentially dangerous URLs (javascript:, data:, etc.) + safe_url = url.match?(/\A(https?:|\/|#)/) ? url : '#' + "#{text}" + end + + # Wiki-style links [[Page Name]] - generate safe internal links + html.gsub!(/\[\[([^\]]+)\]\]/) do + page_name = ::Regexp.last_match(1) + # Sanitize slug to only allow alphanumeric, dashes, underscores + slug = page_name.downcase.gsub(/[^a-z0-9\-_]/, '-').gsub(/-+/, '-') + "#{page_name}" + end + + # Paragraphs + html.gsub!(/\n\n+/, '

') + html = "

#{html}

" + + html.html_safe + end + + # Format a timestamp in a human-readable way + # + # @param time [Time] The timestamp + # @return [String] Formatted time + def format_time(time) + return 'Unknown' unless time + + if time.to_date == Date.today + "Today at #{time.strftime('%H:%M')}" + elsif time.to_date == Date.yesterday + "Yesterday at #{time.strftime('%H:%M')}" + else + time.strftime('%B %d, %Y at %H:%M') + end + end + + # Truncate commit SHA for display + # + # @param sha [String] Full SHA + # @return [String] Short SHA + def short_sha(sha) + sha&.first(7) + end + + # Generate breadcrumb for a page + # + # @param page [Page] The page + # @return [String] HTML breadcrumb + def page_breadcrumb(page) + links = [link_to('Wiki', pages_path)] + links << link_to(page.title, page_path(page)) if page.persisted? + safe_join(links, ' / ') + end + + # Highlight search terms in text + # + # @param text [String] The text to highlight + # @param query [String] The search query + # @return [String] Text with highlighted terms + def highlight_search(text, query) + return ERB::Util.html_escape(text) if query.blank? + + escaped_text = ERB::Util.html_escape(text) + escaped_query = ERB::Util.html_escape(query) + escaped_text.gsub(/(#{Regexp.escape(escaped_query)})/i, '\1').html_safe + end + + # Generate diff HTML from diff output + # + # @param diff [String] Diff output + # @return [String] HTML diff + def format_diff(diff) + return '

No changes

'.html_safe if diff.blank? + + lines = diff.to_s.lines.map do |line| + case line[0] + when '+' + "
#{ERB::Util.html_escape(line)}
" + when '-' + "
#{ERB::Util.html_escape(line)}
" + when '@' + "
#{ERB::Util.html_escape(line)}
" + else + "
#{ERB::Util.html_escape(line)}
" + end + end + + "
#{lines.join}
".html_safe + end +end diff --git a/examples/wiki_cms/app/models/page.rb b/examples/wiki_cms/app/models/page.rb new file mode 100644 index 0000000..d23fb16 --- /dev/null +++ b/examples/wiki_cms/app/models/page.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require 'git_store' + +# Page model that uses GitStore for versioned content storage. +# Each page is stored as a YAML file in the Git repository, providing +# full version history, branching, and rollback capabilities. +# +# @example Creating a new page +# page = Page.new(slug: 'home', title: 'Home Page', content: '# Welcome') +# page.save(author: current_user, message: 'Created home page') +# +# @example Finding and updating a page +# page = Page.find('home') +# page.content = '# Updated Welcome' +# page.save(author: current_user, message: 'Updated home page content') +# +class Page + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations + + # Attributes stored in the YAML file + attribute :slug, :string + attribute :title, :string + attribute :content, :string + attribute :created_at, :datetime + attribute :updated_at, :datetime + attribute :author_name, :string + attribute :author_email, :string + attribute :tags, default: [] + + # Validations + validates :slug, presence: true, format: { with: /\A[a-z0-9\-_]+\z/i, + message: 'only allows alphanumeric characters, dashes, and underscores' } + validates :title, presence: true + validates :content, presence: true + + class << self + # Find a page by its slug + # + # @param slug [String] The page slug + # @return [Page, nil] The page or nil if not found + def find(slug) + data = store["pages/#{slug}.yml"] + return nil unless data + + new(data.merge(slug: slug)) + end + + # Find a page by slug, raising an error if not found + # + # @param slug [String] The page slug + # @return [Page] The page + # @raise [RecordNotFound] If the page doesn't exist + def find!(slug) + find(slug) or raise RecordNotFound, "Page '#{slug}' not found" + end + + # Get all pages in the wiki + # + # @return [Array] All pages sorted by title + def all + pages = [] + store['pages']&.each do |path, data| + next unless path.end_with?('.yml') + slug = path.sub('.yml', '') + pages << new(data.merge(slug: slug)) + end + pages.sort_by(&:title) + end + + # Get recently updated pages + # + # @param limit [Integer] Maximum number of pages to return + # @return [Array] Recently updated pages + def recent(limit: 10) + all.sort_by { |p| p.updated_at || Time.at(0) }.reverse.first(limit) + end + + # Search pages by title or content + # + # @param query [String] Search query + # @return [Array] Matching pages + def search(query) + query_downcase = query.downcase + all.select do |page| + page.title.downcase.include?(query_downcase) || + page.content.downcase.include?(query_downcase) + end + end + + # Check if a page exists + # + # @param slug [String] The page slug + # @return [Boolean] True if the page exists + def exists?(slug) + store["pages/#{slug}.yml"].present? + end + + # Access the GitStore instance + # + # @return [GitStore] The content store + def store + Rails.application.config.content_store + end + end + + # Check if this is a new record + # + # @return [Boolean] True if not yet saved + def new_record? + !self.class.exists?(slug) + end + + # Check if this record has been persisted + # + # @return [Boolean] True if saved + def persisted? + !new_record? + end + + # Save the page to the GitStore + # + # @param author [User, nil] The author making the change + # @param message [String] The commit message + # @return [Boolean] True if saved successfully + def save(author: nil, message: nil) + return false unless valid? + + self.updated_at = Time.current + self.created_at ||= Time.current + + if author + self.author_name = author.respond_to?(:name) ? author.name : author.to_s + self.author_email = author.respond_to?(:email) ? author.email : "#{author}@example.com" + end + + store.transaction(commit_message(message), git_author) do + store["pages/#{slug}.yml"] = to_hash + end + + true + rescue StandardError => e + errors.add(:base, e.message) + false + end + + # Delete the page from the GitStore + # + # @param author [User, nil] The author making the deletion + # @param message [String] The commit message + # @return [Boolean] True if deleted successfully + def destroy(author: nil, message: nil) + return false unless persisted? + + msg = message || "Deleted page: #{title}" + store.transaction(msg, git_author(author)) do + store.delete("pages/#{slug}.yml") + end + + true + rescue StandardError => e + errors.add(:base, e.message) + false + end + + # Get the version history for this page + # + # @param limit [Integer] Maximum number of commits to return + # @return [Array] The commit history + def history(limit: 50) + commits = [] + current = store.head + + while current && commits.size < limit + # Check if this commit modified our page + if commit_affects_page?(current) + commits << current + end + parent_id = current.parent.first if current.parent.any? + current = parent_id ? store.get(parent_id) : nil + end + + commits + end + + # Get the page content at a specific commit + # + # @param commit_id [String] The commit SHA + # @return [Page, nil] The page at that commit + def version_at(commit_id) + commit = store.get(commit_id) + return nil unless commit + + data = commit.tree["pages/#{slug}.yml"] + return nil unless data + + self.class.new(data.merge(slug: slug)) + end + + # Rollback to a previous version + # + # @param commit_id [String] The commit SHA to rollback to + # @param author [User, nil] The author making the rollback + # @param message [String] The commit message + # @return [Boolean] True if rollback was successful + def rollback_to(commit_id:, author: nil, message: nil) + previous_version = version_at(commit_id) + return false unless previous_version + + self.title = previous_version.title + self.content = previous_version.content + self.tags = previous_version.tags + + msg = message || "Rolled back to version #{commit_id[0..7]}" + save(author: author, message: msg) + end + + # Get the diff between this version and another + # + # @param other_commit_id [String] The other commit SHA + # @return [String] The diff output + def diff_with(other_commit_id) + store.head.diff(other_commit_id, "pages/#{slug}.yml") + end + + # Convert to a hash for YAML serialization + # + # @return [Hash] The page data + def to_hash + { + 'title' => title, + 'content' => content, + 'created_at' => created_at&.iso8601, + 'updated_at' => updated_at&.iso8601, + 'author_name' => author_name, + 'author_email' => author_email, + 'tags' => tags + }.compact + end + + # Used for routing + def to_param + slug + end + + private + + def store + self.class.store + end + + def git_author(author = nil) + name = author&.respond_to?(:name) ? author.name : (author_name || 'System') + email = author&.respond_to?(:email) ? author.email : (author_email || 'system@example.com') + GitStore::User.new(name, email, Time.now) + end + + def commit_message(message) + message || (new_record? ? "Created page: #{title}" : "Updated page: #{title}") + end + + def commit_affects_page?(commit) + return false unless commit.parent.any? + + parent = store.get(commit.parent.first) + return true unless parent + + current_blob = commit.tree["pages/#{slug}.yml"] + parent_blob = parent.tree["pages/#{slug}.yml"] + + current_blob != parent_blob + end + + # Custom exception for record not found + class RecordNotFound < StandardError; end +end diff --git a/examples/wiki_cms/app/views/pages/_form.html.erb b/examples/wiki_cms/app/views/pages/_form.html.erb new file mode 100644 index 0000000..ee05b6b --- /dev/null +++ b/examples/wiki_cms/app/views/pages/_form.html.erb @@ -0,0 +1,52 @@ +<%= form_with model: page, url: page.persisted? ? page_path(page) : pages_path, class: 'space-y-4' do |form| %> + <% if page.errors.any? %> +
+

<%= pluralize(page.errors.count, 'error') %> prevented this page from being saved:

+
    + <% page.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :slug, class: 'block text-sm font-medium text-gray-700' %> + <%= form.text_field :slug, + class: 'mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500', + placeholder: 'page-slug', + disabled: page.persisted? %> +

URL-friendly identifier (letters, numbers, dashes)

+
+ +
+ <%= form.label :title, class: 'block text-sm font-medium text-gray-700' %> + <%= form.text_field :title, + class: 'mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500', + placeholder: 'Page Title' %> +
+ +
+ <%= form.label :content, class: 'block text-sm font-medium text-gray-700' %> + <%= form.text_area :content, + rows: 15, + class: 'mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono', + placeholder: 'Write your content in Markdown...' %> +

Supports Markdown formatting and [[Wiki Links]]

+
+ +
+ <%= label_tag :commit_message, 'Commit Message', class: 'block text-sm font-medium text-gray-700' %> + <%= text_field_tag :commit_message, nil, + class: 'mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500', + placeholder: page.persisted? ? 'Describe your changes...' : 'Initial page creation' %> +

Describe what you changed (for version history)

+
+ +
+ <%= form.submit page.persisted? ? 'Update Page' : 'Create Page', + class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded cursor-pointer' %> + <%= link_to 'Cancel', page.persisted? ? page_path(page) : pages_path, + class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %> +
+<% end %> diff --git a/examples/wiki_cms/app/views/pages/diff.html.erb b/examples/wiki_cms/app/views/pages/diff.html.erb new file mode 100644 index 0000000..c2a96c8 --- /dev/null +++ b/examples/wiki_cms/app/views/pages/diff.html.erb @@ -0,0 +1,73 @@ +
+ + +

Changes: <%= @page.title %>

+ +
+
+

+ From: <%= short_sha(@from_commit) %> +

+ <% if @from_version %> +

+ <%= format_time(@from_version.updated_at) %> + by <%= @from_version.author_name %> +

+ <% end %> +
+
+

+ To: <%= short_sha(@to_commit) %> +

+ <% if @to_version %> +

+ <%= format_time(@to_version.updated_at) %> + by <%= @to_version.author_name %> +

+ <% end %> +
+
+ +
+

Diff

+ <%= format_diff(@diff) %> +
+ +
+ <%= link_to 'Back to History', history_page_path(@page), class: 'text-blue-600 hover:text-blue-900' %> + <%= button_to "Rollback to #{short_sha(@from_commit)}", + rollback_page_path(@page, commit_id: @from_commit), + method: :post, + class: 'bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded', + data: { turbo_confirm: 'Are you sure you want to rollback to this version?' } %> +
+
+ + diff --git a/examples/wiki_cms/app/views/pages/edit.html.erb b/examples/wiki_cms/app/views/pages/edit.html.erb new file mode 100644 index 0000000..dfc9285 --- /dev/null +++ b/examples/wiki_cms/app/views/pages/edit.html.erb @@ -0,0 +1,5 @@ +
+

Edit: <%= @page.title %>

+ + <%= render 'form', page: @page %> +
diff --git a/examples/wiki_cms/app/views/pages/history.html.erb b/examples/wiki_cms/app/views/pages/history.html.erb new file mode 100644 index 0000000..099660d --- /dev/null +++ b/examples/wiki_cms/app/views/pages/history.html.erb @@ -0,0 +1,67 @@ +
+ + +

History: <%= @page.title %>

+ +
+ + + + + + + + + + + + <% @commits.each_with_index do |commit, index| %> + + + + + + + + <% end %> + +
CommitMessageAuthorDateActions
+ + <%= short_sha(commit.id) %> + + <% if index == 0 %> + + Current + + <% end %> + +
<%= truncate(commit.message, length: 60) %>
+
+
<%= commit.author&.name %>
+
<%= commit.author&.email %>
+
+ <%= format_time(commit.author&.time) %> + +
+ <% if index > 0 %> + <%= link_to 'View', page_path(@page, version: commit.id), + class: 'text-blue-600 hover:text-blue-900' %> + <%= link_to 'Diff', diff_page_path(@page, from: commit.id, to: @commits[index - 1].id), + class: 'text-blue-600 hover:text-blue-900' %> + <%= button_to 'Rollback', rollback_page_path(@page, commit_id: commit.id), + method: :post, + class: 'text-red-600 hover:text-red-900', + data: { turbo_confirm: "Are you sure you want to rollback to version #{short_sha(commit.id)}?" } %> + <% end %> +
+
+
+ +
+ <%= link_to 'Back to Page', page_path(@page), class: 'text-blue-600 hover:text-blue-900' %> +
+
diff --git a/examples/wiki_cms/app/views/pages/index.html.erb b/examples/wiki_cms/app/views/pages/index.html.erb new file mode 100644 index 0000000..8b04b88 --- /dev/null +++ b/examples/wiki_cms/app/views/pages/index.html.erb @@ -0,0 +1,50 @@ +
+
+

Wiki Pages

+ <%= link_to 'New Page', new_page_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %> +
+ +
+ <%= form_with url: search_pages_path, method: :get, class: 'flex gap-2' do %> + <%= text_field_tag :q, params[:q], placeholder: 'Search pages...', class: 'flex-1 px-4 py-2 border rounded' %> + <%= submit_tag 'Search', class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded cursor-pointer' %> + <% end %> +
+ + <% if @query.present? %> +

+ Found <%= pluralize(@pages.size, 'result') %> for "<%= @query %>" +

+ <% end %> + +
+ <% if @pages.empty? %> +
+

No pages yet. <%= link_to 'Create the first page', new_page_path, class: 'text-blue-500 hover:underline' %>

+
+ <% else %> + <% @pages.each do |page| %> +
+

+ <%= link_to page.title, page_path(page), class: 'text-blue-600 hover:text-blue-800' %> +

+

+ <% if @query.present? %> + <%= highlight_search(truncate(page.content, length: 200), @query) %> + <% else %> + <%= truncate(page.content, length: 200) %> + <% end %> +

+
+ <% if page.updated_at %> + Updated <%= format_time(page.updated_at) %> + <% end %> + <% if page.author_name %> + by <%= page.author_name %> + <% end %> +
+
+ <% end %> + <% end %> +
+
diff --git a/examples/wiki_cms/app/views/pages/new.html.erb b/examples/wiki_cms/app/views/pages/new.html.erb new file mode 100644 index 0000000..f5b27ee --- /dev/null +++ b/examples/wiki_cms/app/views/pages/new.html.erb @@ -0,0 +1,5 @@ +
+

New Page

+ + <%= render 'form', page: @page %> +
diff --git a/examples/wiki_cms/app/views/pages/show.html.erb b/examples/wiki_cms/app/views/pages/show.html.erb new file mode 100644 index 0000000..5c7e939 --- /dev/null +++ b/examples/wiki_cms/app/views/pages/show.html.erb @@ -0,0 +1,41 @@ +
+ + +
+
+

<%= h @page.title %>

+
+ <% if @page.updated_at %> + Last updated: <%= format_time(@page.updated_at) %> + <% end %> + <% if @page.author_name %> + by <%= h @page.author_name %> + <% end %> +
+ <% if @page.tags.present? %> +
+ <% @page.tags.each do |tag| %> + + <%= h tag %> + + <% end %> +
+ <% end %> +
+ +
+ <%= render_markdown(@page.content) %> +
+
+ +
+ <%= link_to 'Edit', edit_page_path(@page), class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %> + <%= link_to 'History', history_page_path(@page), class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %> + <%= link_to 'Raw', raw_page_path(@page), class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %> + <%= button_to 'Delete', page_path(@page), method: :delete, + class: 'bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded', + data: { turbo_confirm: 'Are you sure you want to delete this page?' } %> +
+
diff --git a/examples/wiki_cms/config/initializers/git_store.rb b/examples/wiki_cms/config/initializers/git_store.rb new file mode 100644 index 0000000..557707c --- /dev/null +++ b/examples/wiki_cms/config/initializers/git_store.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# GitStore configuration for Rails 8 +# This initializer sets up the GitStore connection for content storage + +Rails.application.config.to_prepare do + # Path to the content repository + content_repo_path = Rails.root.join('content_repo') + + # Ensure the repository exists + unless File.exist?(content_repo_path.join('.git')) + FileUtils.mkdir_p(content_repo_path) + Dir.chdir(content_repo_path) do + # Use array form to avoid shell injection + system('git', 'init') + system('git', 'config', 'user.name', 'Wiki System') + system('git', 'config', 'user.email', 'wiki@example.com') + end + end + + # Initialize the global GitStore instance + Rails.application.config.content_store = GitStore.new(content_repo_path.to_s) +end + +# Helper method to access the store from anywhere +module GitStoreHelper + def content_store + Rails.application.config.content_store + end +end diff --git a/examples/wiki_cms/config/routes.rb b/examples/wiki_cms/config/routes.rb new file mode 100644 index 0000000..af4c5df --- /dev/null +++ b/examples/wiki_cms/config/routes.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + # Wiki pages routes + resources :pages, param: :slug do + member do + get :history + get :diff + post :rollback + get :raw + end + collection do + get :recent + get :search + end + end + + # Set wiki home as root + root 'pages#show', slug: 'home' +end