What follows is another basic solution using ActiveRecord only and CGI. There is minimal CSS styling, but you could add some very easily. What is interesting about this solution is that it's very small (a single file and a single table), does not need users and sets itself up. The solution uses sqlite3 (via gems) and checks if the database file exists as part of the startup. If it doesn't exist, it creates the file and the table that it needs. Listing, searching, viewing details, creating new posts and closing posts are supported. Each new post generates a 'secret' that the person posting can then use to close the post with latter, such that users are not required. Posts can also be 'administratively' closed. Additionally, although this solution is a single file, all the interfaces are templated using ERB. Each template is a separate entry after the __END__ marker, with the first non-whitespace line being the name and all lines after until the separater line as the file contents. DRY principles are also in place as the header/footer are seperate templates and included into each page rather then being repeated. ---------------------- Copy the following and paste it into a .cgi file. It has been tested with lighttpd and apache. ---------------------- #!/usr/bin/env ruby ## Proposed solution to http://www.rubyquiz.org/quiz047.html ## Written by Paul Vaillant (paul.vaillant / gmail.com) ## Permission granted to do whatever you'd like with this code require 'digest/md5' require 'cgi' require 'erb' ## gems are required for sqlite3 and active_record require 'rubygems' require 'sqlite3' require 'active_record' ## Check if the database exists; create it and the table we need if it doesn't DB_FILE = "/tmp/jobs.db" unless File.readable?(DB_FILE) table_def = <<-EOD CREATE TABLE postings ( id INTEGER PRIMARY KEY AUTOINCREMENT, posted INTEGER, title VARCHAR(255), company VARCHAR(255), location VARCHAR(255), length VARCHAR(255), contact VARCHAR(255), travel INTEGER(2), -- 0%, 0-25%, 25-50%, 50-75%, 75-100% onsite INTEGER(1), description TEXT, requirements TEXT, terms INTEGER(2), -- C(hourly), C(project), E(hourly), E(pt), E(ft) hours VARCHAR(255), secret VARCHAR(255) UNIQUE, closed INTEGER(1) DEFAULT 0 ); EOD db = SQLite3::Database.new(DB_FILE) db.execute(table_def) db.close end ## Setup ActiveRecord database connection and the one ORM class we need ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => DB_FILE) class Posting < ActiveRecord::Base TRAVEL = ['0%','0-25%','25-50%','50-75%','75-100%'] TERMS = ['Contract(hourly)','Contract(project)','Employee(hourly)', 'Employee(part-time)','Employee(full-time)'] end class Actions ADMIN_SECRET = 's3cr3t' @@templates = nil def self.template(t) unless @@templates @@templates = Hash.new name = nil data = '' DATA.each_line {|l| if name.nil? name = l.strip elsif l.strip == '-=-=-=-=-' @@templates[name] = data if name name = nil data = '' else data << l.strip << "\n" end unless l =~ /^\s*$/ } @@templates[name] = data if name end return @@templates[t] end def self.dispatch() cgi = CGI.new begin ## map path_info to the method that handles it (ie controller) ## ex. no path_info (/jobs.cgi) goes to 'index' ## /search (/jobs.cgi/search) goes to 'search' ## /create/save (/jobs.cgi/create/save) goes to 'create__save' action = if cgi.path_info a = cgi.path_info[1,cgi.path_info.length-1].gsub(/\//,'__') (a && a != '' ? a : 'index') else "index" end a = Actions.new(cgi) m = a.method(action.to_sym) if m && m.arity == 0 resbody = m.call() else raise "Failed to locate valid handler for [#{action}]" end rescue Exception => e puts cgi.header('text/plain') puts "EXCEPTION: #{e.message}" puts e.backtrace.join("\n") else puts cgi.header() puts resbody end end attr_reader :cgi def initialize(cgi) @cgi = cgi end def index @postings = Posting.find(:all, :conditions => ['closed = 0'], :order => 'posted desc', :limit => 10) render('index') end def search q = '%' << (cgi['q'] || '') << '%' conds = ['closed = 0 AND (description like ? OR requirements like ? OR title like ?)', q, q, q] @postings = Posting.find(:all, :conditions => conds, :order => 'posted desc') render('index') end def view id = cgi['id'].to_i @post = Posting.find(id) render('view') end def create if cgi['save'] && cgi['save'] != '' post = Posting.new post.posted = Time.now().to_i ['title','company','location','length','contact', 'description','requirements','hours'].each {|f| post[f] = cgi[f] } ['travel','onsite','terms'].each {|f| post[f] = cgi[f].to_i } post.secret = Digest::MD5.hexdigest([rand(),Time.now.to_i,$$].join("|")) post.closed = 0 if post.save @post = post end end render('create') end def close ## match secret OR id+ADMIN_SECRET secret = cgi['secret'] if secret =~ /^(\d+)\+(.+)$/ id,admin_secret = secret.split(/\+/) post = Posting.find(id.to_i) if admin_secret == ADMIN_SECRET else post = Posting.find(:first, :conditions => ['secret = ?', secret]) end if post post.closed = 1 post.save @post = post else @error = "Failed to match given secret to your post" end render('close') end ## helper methods def link_to(name, url_frag) return "<a href=\"#{ENV['SCRIPT_NAME']}/#{url_frag}\">#{name}</a>" end def form_tag(url_frag, meth="POST") return "<form method=\"#{meth}\" action=\"#{ENV['SCRIPT_NAME']}/#{url_frag}\">" end def select(name, options, selected=nil) sel = "<select name=\"#{name}\">" options.each_with_index {|o,i| sel << "<option value=\"#{i}\" #{(i == selected ? "selected=\"1\"" : '')}>#{o}</option>" } sel << "</select>" return sel end def radio_yn(name,val=1) val ||= 1 radio = "Yes <input type=\"radio\" name=\"#{name}\" value=\"1\" #{(val == 1 ? "checked=\"checked\"": '')}/> / " radio << "No <input type=\"radio\" name=\"#{name}\" value=\"0\" #{(val == 0 ? "checked=\"checked\"" : '')} />" return radio end def textfield(name,val) return "<input type=\"text\" name=\"#{name}\" value=\"#{val}\" />" end def textarea(name,val) return "<textarea name=\"#{name}\" rows=\"7\" cols=\"60\">" << CGI.escapeHTML(val || '') << "</textarea>" end def render(name) return ERB.new(Actions.template(name),nil,'%<>').result(binding) end end Actions.dispatch __END__ index <%= render('header') %> <h1>Postings</h1> <% if @postings.empty? %> <p>Sorry, no job postings at this time.</p> <% else %> <% for post in @postings %> <p><%= link_to post.title, "view?id=#{post.id}" %>, <%= post.company %><br /> <%= post.location %> (<%= Time.at(0).strftime('%Y-%m-%d') %>)</p> <% end %> </table> <% end %> <%= render('footer') %> -=-=-=-=- create <%= render('header') %> <h1>Create new Post</h1> <% if @post %> <p>Your post has been successfully added. Please note the following information, as you will need it to close you post once it has been filled; <br /><br /> Close code: <%= @post.secret %></p> <p>Thank you</p> <% else %> <% if @error %><p class="error">ERROR: <%= @error %></p><% end %> <%= form_tag "create" %> <label for="title">Title</label> <%= textfield "title", cgi['title'] %><br /> <label for="company">Company</label> <%= textfield "company", cgi['company'] %><br /> <label for="location">Location</label> <%= textfield "location", cgi['location'] %><br /> <label for="length">Length</label> <%= textfield "length", cgi['length'] %><br /> <label for="contact">Contact</label> <%= textfield "contact", cgi['contact'] %><br /> <label for="travel">Travel</label> <%= select 'travel', Posting::TRAVEL, cgi['travel'] %><br /> <label for="onsite">Onsite</label> <%= radio_yn "onsite", cgi['onsite'] %><br /> <label for="description">Description</label> <%= textarea "description", cgi['description'] %><br /> <label for="requirements">Requirements</label> <%= textarea "requirements", cgi['requirements'] %><br /> <label for="terms">Employment Terms</label> <%= select 'terms', Posting::TERMS, cgi['terms'] %><br /> <label for="hours">Hours</label> <%= textfield "hours", cgi['hours'] %><br /> <input type="submit" name="save" value="create" /> </form> <% end %> <%= render('footer') %> -=-=-=-=- view <%= render('header') %> <% if @post %> <h1><%= @post.title %></h1> <table> <tr><td>Posted</td><td><%= Time.at(@post.posted.to_i).strftime('%Y-%m-%d') %></td></tr> <tr><td>Company</td><td><%= @post.company %></td></tr> <tr><td>Length of employment</td><td><%= @post.length %></td></tr> <tr><td>Contact info</td><td><%= @post.contact %></td></tr> <tr><td>Travel</td><td><%= Posting::TRAVEL[@post.travel] %></td></tr> <tr><td>Onsite</td><td><%= ['No','Yes'][@post.onsite] %></td></tr> <tr><td>Description</td><td><%= CGI.escapeHTML(@post.description).gsub(/\n/,"<br />\n") %></td></tr> <tr><td>Requirements</td><td><%= CGI.escapeHTML(@post.requirements).gsub(/\n/,"<br />\n") %></td></tr> <tr><td>Employment terms</td><td><%= Posting::TERMS[@post.terms] %></td></tr> <tr><td>Hours</td><td><%= @post.hours %></td></tr> </table> <% else %> <p>ERROR: failed to load given post.</p> <% end %> <%= render('footer') %> -=-=-=-=- close <%= render('header') %> <h1>Close Post</h1> <% if @post %> <p>Successfully closed post '<%= @post.title %>' by <%= @post.company %>.</p> <% elsif @error %> <p>ERROR: <%= @error %></p> <% else %> <p>ERROR: post not successfully closed, no further description of error.</p> <% end %> <%= render('footer') %> -=-=-=-=- header <html> <head> <title>Simple Job Site</title> <style> form { display: inline; } </style> </head> <body> <%= link_to "Home", "index" %> | <%= link_to "Create new Post", "create" %> | <%= form_tag "close" %> <input name="secret" type="text" size="16" /> <input type="submit" value="close" /> </form> | <%= form_tag "search" %> <input name="q" type="text" size="15" /> <input type="submit" value="search" /> </form><br /> -=-=-=-=- footer </body> </html>