lib/showoff.rb in showoff-0.7.0 vs lib/showoff.rb in showoff-0.9.7

- old
+ new

@@ -2,110 +2,164 @@ require 'sinatra/base' require 'json' require 'nokogiri' require 'fileutils' require 'logger' +require 'htmlentities' +require 'sinatra-websocket' here = File.expand_path(File.dirname(__FILE__)) require "#{here}/showoff_utils" require "#{here}/commandline_parser" begin require 'RMagick' rescue LoadError - $stderr.puts 'image sizing disabled - install rmagick' + $stderr.puts 'WARN: image sizing disabled - install rmagick' end begin require 'pdfkit' rescue LoadError - $stderr.puts 'pdf generation disabled - install pdfkit' + $stderr.puts 'WARN: pdf generation disabled - install pdfkit' end -begin - require 'rdiscount' -rescue LoadError - require 'bluecloth' - Object.send(:remove_const,:Markdown) - Markdown = BlueCloth -end +require 'tilt' class ShowOff < Sinatra::Application - Version = VERSION = '0.7.0' - attr_reader :cached_image_size + # Set up application variables + set :views, File.dirname(__FILE__) + '/../views' - set :public, File.dirname(__FILE__) + '/../public' + set :public_folder, File.dirname(__FILE__) + '/../public' + set :statsdir, "stats" + set :viewstats, "viewstats.json" + set :feedback, "feedback.json" + + set :server, 'thin' + set :sockets, [] + set :presenters, [] + set :verbose, false set :pres_dir, '.' set :pres_file, 'showoff.json' + set :page_size, "Letter" + set :pres_template, nil + set :showoff_config, {} + set :encoding, nil + FileUtils.mkdir settings.statsdir unless File.directory? settings.statsdir + + # Page view time accumulator. Tracks how often slides are viewed by the audience + begin + @@counter = JSON.parse(File.read("#{settings.statsdir}/#{settings.viewstats}")) + rescue + @@counter = Hash.new + end + + @@downloads = Hash.new # Track downloadable files + @@cookie = nil # presenter cookie. Identifies the presenter for control messages + @@current = Hash.new # The current slide that the presenter is viewing + def initialize(app=nil) super(app) @logger = Logger.new(STDOUT) @logger.formatter = proc { |severity,datetime,progname,msg| "#{progname} #{msg}\n" } - @logger.level = options.verbose ? Logger::DEBUG : Logger::WARN + @logger.level = settings.verbose ? Logger::DEBUG : Logger::WARN dir = File.expand_path(File.join(File.dirname(__FILE__), '..')) @logger.debug(dir) showoff_dir = File.expand_path(File.join(File.dirname(__FILE__), '..')) - options.pres_dir ||= Dir.pwd + settings.pres_dir ||= Dir.pwd @root_path = "." - options.pres_dir = File.expand_path(options.pres_dir) - if (options.pres_file) - ShowOffUtils.presentation_config_file = options.pres_file + settings.pres_dir = File.expand_path(settings.pres_dir) + if (settings.pres_file) + ShowOffUtils.presentation_config_file = settings.pres_file end + + # Load configuration for page size and template from the + # configuration JSON file + if File.exists?(ShowOffUtils.presentation_config_file) + showoff_json = JSON.parse(File.read(ShowOffUtils.presentation_config_file)) + settings.showoff_config = showoff_json + + # Set options for encoding, template and page size + settings.encoding = showoff_json["encoding"] + settings.page_size = showoff_json["page-size"] || "Letter" + settings.pres_template = showoff_json["templates"] + end + + @logger.debug settings.pres_template + @cached_image_size = {} - @logger.debug options.pres_dir - @pres_name = options.pres_dir.split('/').pop + @logger.debug settings.pres_dir + @pres_name = settings.pres_dir.split('/').pop require_ruby_files + + # Default asset path + @asset_path = "./" + + + # Initialize Markdown Configuration + MarkdownConfig::setup(settings.pres_dir) end def self.pres_dir_current opt = {:pres_dir => Dir.pwd} ShowOff.set opt end def require_ruby_files - Dir.glob("#{options.pres_dir}/*.rb").map { |path| require path } + Dir.glob("#{settings.pres_dir}/*.rb").map { |path| require path } end helpers do def load_section_files(section) - section = File.join(options.pres_dir, section) + section = File.join(settings.pres_dir, section) files = if File.directory? section Dir.glob("#{section}/**/*").sort else [section] end @logger.debug files files end def css_files - Dir.glob("#{options.pres_dir}/*.css").map { |path| File.basename(path) } + Dir.glob("#{settings.pres_dir}/*.css").map { |path| File.basename(path) } end def js_files - Dir.glob("#{options.pres_dir}/*.js").map { |path| File.basename(path) } + Dir.glob("#{settings.pres_dir}/*.js").map { |path| File.basename(path) } end def preshow_files - Dir.glob("#{options.pres_dir}/_preshow/*").map { |path| File.basename(path) }.to_json + Dir.glob("#{settings.pres_dir}/_preshow/*").map { |path| File.basename(path) }.to_json end # todo: move more behavior into this class class Slide - attr_reader :classes, :text - def initialize classes = "" - @classes = ["content"] + classes.strip.chomp('>').split + attr_reader :classes, :text, :tpl, :bg + def initialize( context = "") + + @tpl = "default" + @classes = [] + + # Parse the context string for options and content classes + if context and context.match(/(\[(.*?)\])?(.*)/) + options = ShowOffUtils.parse_options($2) + @tpl = options["tpl"] if options["tpl"] + @bg = options["bg"] if options["bg"] + @classes += $3.strip.chomp('>').split if $3 + end + @text = "" end def <<(s) @text << s @text << "\n" @@ -113,13 +167,18 @@ def empty? @text.strip == "" end end + def process_markdown(name, content, opts={:static=>false, :pdf=>false, :print=>false, :toc=>false, :supplemental=>nil}) + if settings.encoding and content.respond_to?(:force_encoding) + content.force_encoding(settings.encoding) + end + engine_options = ShowOffUtils.showoff_renderer_options(settings.pres_dir) + @logger.debug "renderer: #{Tilt[:markdown].name}" + @logger.debug "render options: #{engine_options.inspect}" - def process_markdown(name, content, static=false, pdf=false) - # if there are no !SLIDE markers, then make every H1 define a new slide unless content =~ /^\<?!SLIDE/m content = content.gsub(/^# /m, "<!SLIDE>\n# ") end @@ -129,11 +188,12 @@ slides = [] slides << (slide = Slide.new) until lines.empty? line = lines.shift if line =~ /^<?!SLIDE(.*)>?/ - slides << (slide = Slide.new($1)) + ctx = $1 ? $1.strip : $1 + slides << (slide = Slide.new(ctx)) else slide << line end end @@ -142,56 +202,244 @@ final = '' if slides.size > 1 seq = 1 end slides.each do |slide| - md = '' + # update section counters before we reject slides so the numbering is consistent + if slide.classes.include? 'subsection' + @section_major += 1 + @section_minor = 0 + end + + if opts[:supplemental] + # if we're looking for supplemental material, only include the content we want + next unless slide.classes.include? 'supplemental' + next unless slide.classes.include? opts[:supplemental] + else + # otherwise just skip all supplemental material completely + next if slide.classes.include? 'supplemental' + end + + unless opts[:toc] + # just drop the slide if we're not generating a table of contents + next if slide.classes.include? 'toc' + end + + if opts[:print] + # drop all slides not intended for the print version + next if slide.classes.include? 'noprint' + else + # drop slides that are intended for the print version only + next if slide.classes.include? 'printonly' + end + + @slide_count += 1 content_classes = slide.classes # extract transition, defaulting to none transition = 'none' content_classes.delete_if { |x| x =~ /^transition=(.+)/ && transition = $1 } # extract id, defaulting to none id = nil content_classes.delete_if { |x| x =~ /^#([\w-]+)/ && id = $1 } + id = name unless id @logger.debug "id: #{id}" if id @logger.debug "classes: #{content_classes.inspect}" @logger.debug "transition: #{transition}" - # create html - md += "<div" - md += " id=\"#{id}\"" if id - md += " class=\"slide\" data-transition=\"#{transition}\">" + @logger.debug "tpl: #{slide.tpl} " if slide.tpl + @logger.debug "bg: #{slide.bg}" if slide.bg + + + template = "~~~CONTENT~~~" + # Template handling + if settings.pres_template + # We allow specifying a new template even when default is + # not given. + if settings.pres_template.include?(slide.tpl) and + File.exists?(settings.pres_template[slide.tpl]) + template = File.open(settings.pres_template[slide.tpl], "r").read() + end + end + + # create html for the slide + classes = content_classes.join(' ') + content = "<div" + content += " id=\"#{id}\"" if id + content += " style=\"background: url('file/#{slide.bg}') center no-repeat;\"" if slide.bg + content += " class=\"slide #{classes}\" data-transition=\"#{transition}\">" + + # name the slide. If we've got multiple slides in this file, we'll have a sequence number + # include that sequence number to index directly into that content if seq - md += "<div class=\"#{content_classes.join(' ')}\" ref=\"#{name}/#{seq.to_s}\">\n" - seq += 1 + content += "<div class=\"content #{classes}\" ref=\"#{name}/#{seq.to_s}\">\n" else - md += "<div class=\"#{content_classes.join(' ')}\" ref=\"#{name}\">\n" + content += "<div class=\"content #{classes}\" ref=\"#{name}\">\n" end - sl = Markdown.new(slide.text).to_html - sl = update_image_paths(name, sl, static, pdf) - md += sl - md += "</div>\n" - md += "</div>\n" - final += update_commandline_code(md) - final = update_p_classes(final) + + # Apply the template to the slide and replace the key to generate the content of the slide + sl = process_content_for_replacements(template.gsub(/~~~CONTENT~~~/, slide.text)) + sl = Tilt[:markdown].new(nil, nil, engine_options) { sl }.render + sl = update_p_classes(sl) + sl = process_content_for_section_tags(sl) + sl = update_special_content(sl, @slide_count, name) # TODO: deprecated + sl = update_image_paths(name, sl, opts) + + content += sl + content += "</div>\n" + content += "</div>\n" + + final += update_commandline_code(content) + + if seq + seq += 1 + end end final end + # This method processes the content of the slide and replaces + # content markers with their actual value information + def process_content_for_replacements(content) + # update counters, incrementing section:minor if needed + result = content.gsub("~~~CURRENT_SLIDE~~~", @slide_count.to_s) + result.gsub!("~~~SECTION:MAJOR~~~", @section_major.to_s) + if result.include? "~~~SECTION:MINOR~~~" + @section_minor += 1 + result.gsub!("~~~SECTION:MINOR~~~", @section_minor.to_s) + end + + # scan for pagebreak tags. Should really only be used for handout notes or supplemental materials + result.gsub!("~~~PAGEBREAK~~~", '<div class="break">continued...</div>') + + # Now check for any kind of options + content.scan(/(~~~CONFIG:(.*?)~~~)/).each do |match| + result.gsub!(match[0], settings.showoff_config[match[1]]) if settings.showoff_config.key?(match[1]) + end + + # Load and replace any file tags + content.scan(/(~~~FILE:([^:]*):?(.*)?~~~)/).each do |match| + # get the file content and parse out html entities + file = HTMLEntities.new.encode(File.read(File.join(settings.pres_dir, '_files', match[1]))) + + # make a list of sh_highlight classes to include + css = match[2].split.collect {|i| "sh_#{i.downcase}" }.join(' ') + + result.gsub!(match[0], "<pre class=\"#{css}\"><code>#{file}</code></pre>") + end + + result + end + + # replace section tags with classed div tags + def process_content_for_section_tags(content) + return unless content + + # because this is post markdown rendering, we may need to shift a <p> tag around + # remove the tags if they're by themselves + result = content.gsub(/<p>~~~SECTION:([^~]*)~~~<\/p>/, '<div class="\1">') + result.gsub!(/<p>~~~ENDSECTION~~~<\/p>/, '</div>') + + # shove it around the div if it belongs to the contained element + result.gsub!(/(<p>)?~~~SECTION:([^~]*)~~~/, '<div class="\2">\1') + result.gsub!(/~~~ENDSECTION~~~(<\/p>)?/, '\1</div>') + + result + end + + def process_content_for_all_slides(content, num_slides, opts={}) + content.gsub!("~~~NUM_SLIDES~~~", num_slides.to_s) + + # Should we build a table of contents? + if opts[:toc] + frag = Nokogiri::HTML::DocumentFragment.parse "" + toc = Nokogiri::XML::Node.new('div', frag) + toc['id'] = 'toc' + frag.add_child(toc) + + Nokogiri::HTML(content).css('div.subsection > h1').each do |section| + entry = Nokogiri::XML::Node.new('div', frag) + entry['class'] = 'tocentry' + toc.add_child(entry) + + link = Nokogiri::XML::Node.new('a', frag) + link['href'] = "##{section.parent.parent['id']}" + link.content = section.content + entry.add_child(link) + end + + # swap out the tag, if found, with the table of contents + content.gsub!("~~~TOC~~~", frag.to_html) + end + + content + end + # find any lines that start with a <p>.(something) and turn them into <p class="something"> def update_p_classes(markdown) markdown.gsub(/<p>\.(.*?) /, '<p class="\1">') end - def update_image_paths(path, slide, static=false, pdf=false) + # TODO: deprecated + def update_special_content(content, seq, name) + doc = Nokogiri::HTML::DocumentFragment.parse(content) + %w[notes handouts instructor solguide].each { |mark| update_special_content_mark(doc, mark) } + update_download_links(doc, seq, name) + + # TODO: what the bloody hell. Figure out how to either make Nokogiri output closed + # tags or figure out how to get its XML output to quit adding gratuitious spaces. + doc.to_html.gsub(/(<img [^>]*)>/, '\1 />') + end + + # TODO: deprecated + def update_special_content_mark(doc, mark) + container = doc.css("p.#{mark}").first + return unless container + + @logger.warn "Special mark (#{mark}) is deprecated. Please replace with section tags. See the README for details." + + # only allow localhost to print the instructor guide + if mark == 'instructor' and request.env['REMOTE_HOST'] != 'localhost' + container.remove + else + raw = container.inner_html + fixed = raw.gsub(/^\.#{mark} ?/, '') + markdown = Tilt[:markdown].new { fixed }.render + + container.name = 'div' + container.inner_html = markdown + end + end + private :update_special_content_mark + + def update_download_links(doc, seq, name) + container = doc.css("p.download").first + return unless container + + raw = container.text + fixed = raw.gsub(/^\.download ?/, '') + + # first create the data structure + # [ enabled, slide name, [array, of, files] ] + @@downloads[seq] = [ false, name, [] ] + + fixed.split("\n").each { |file| + # then push each file onto the list + @@downloads[seq][2].push(file.strip) + } + + container.remove + end + private :update_download_links + + def update_image_paths(path, slide, opts={:static=>false, :pdf=>false}) paths = path.split('/') paths.pop path = paths.join('/') - replacement_prefix = static ? - ( pdf ? %(img src="file://#{options.pres_dir}/#{path}) : %(img src="./file/#{path}) ) : - %(img src="/image/#{path}) - slide.gsub(/img src=\"([^\/].*?)\"/) do |s| + replacement_prefix = opts[:static] ? + ( opts[:pdf] ? %(img src="file://#{settings.pres_dir}/#{path}) : %(img src="./file/#{path}) ) : + %(img src="#{@asset_path}image/#{path}) + slide.gsub(/img src=[\"\'](?!https?:\/\/)([^\/].*?)[\"\']/) do |s| img_path = File.join(path, $1) w, h = get_image_size(img_path) src = %(#{replacement_prefix}/#{$1}") if w && h src << %( width="#{w}" height="#{h}") @@ -226,11 +474,11 @@ pre.css('code').each do |code| out = code.text lines = out.split("\n") if lines.first.strip[0, 3] == '@@@' lang = lines.shift.gsub('@@@', '').strip - pre.set_attribute('class', 'sh_' + lang.downcase) + pre.set_attribute('class', 'sh_' + lang.downcase) if !lang.empty? code.content = lines.join("\n") end end end @@ -262,41 +510,45 @@ transform.apply(tree) end html.root.to_s end - def get_slides_html(static=false, pdf=false) - sections = ShowOffUtils.showoff_sections(options.pres_dir, @logger) + def get_slides_html(opts={:static=>false, :pdf=>false, :toc=>false, :supplemental=>nil}) + @slide_count = 0 + @section_major = 0 + @section_minor = 0 + + sections = ShowOffUtils.showoff_sections(settings.pres_dir, @logger) files = [] if sections data = '' sections.each do |section| if section =~ /^#/ name = section.each_line.first.gsub(/^#*/,'').strip - data << process_markdown(name, "<!SLIDE subsection>\n" + section, static, pdf) + data << process_markdown(name, "<!SLIDE subsection>\n" + section, opts) else files = [] files << load_section_files(section) files = files.flatten - files = files.select { |f| f =~ /.md/ } + files = files.select { |f| f =~ /.md$/ } files.each do |f| - fname = f.gsub(options.pres_dir + '/', '').gsub('.md', '') - data << process_markdown(fname, File.read(f), static, pdf) + fname = f.gsub(settings.pres_dir + '/', '').gsub('.md', '') + data << process_markdown(fname, File.read(f), opts) end end end end - data + process_content_for_all_slides(data, @slide_count, opts) end def inline_css(csses, pre = nil) css_content = '<style type="text/css">' csses.each do |css_file| if pre css_file = File.join(File.dirname(__FILE__), '..', pre, css_file) else - css_file = File.join(options.pres_dir, css_file) + css_file = File.join(settings.pres_dir, css_file) end css_content += File.read(css_file) end css_content += '</style>' css_content @@ -306,13 +558,19 @@ js_content = '<script type="text/javascript">' jses.each do |js_file| if pre js_file = File.join(File.dirname(__FILE__), '..', pre, js_file) else - js_file = File.join(options.pres_dir, js_file) + js_file = File.join(settings.pres_dir, js_file) end - js_content += File.read(js_file) + + begin + js_content += File.read(js_file) + rescue Errno::ENOENT + $stderr.puts "WARN: Failed to inline JS. No such file: #{js_file}" + next + end end js_content += '</script>' js_content end @@ -320,18 +578,29 @@ inline_js(Dir.entries(File.join(File.dirname(__FILE__), '..', jses_directory)).find_all{|filename| filename.length > 2 }, jses_directory) end def index(static=false) if static - @title = ShowOffUtils.showoff_title - @slides = get_slides_html(static) + @title = ShowOffUtils.showoff_title(settings.pres_dir) + @slides = get_slides_html(:static=>static) + @pause_msg = ShowOffUtils.pause_msg + + # Identify which languages to bundle for highlighting + @languages = @slides.scan(/<pre class=".*(?!sh_sourceCode)(sh_[\w-]+).*"/).uniq.map{ |w| "sh_lang/#{w[0]}.min.js"} + @asset_path = "./" end + + # Check to see if the presentation has enabled feedback + @feedback = settings.showoff_config['feedback'] erb :index end def presenter + @issues = settings.showoff_config['issues'] + @@cookie ||= guid() + response.set_cookie('presenter', @@cookie) erb :presenter end def clean_link(href) if href && href[0, 1] == '/' @@ -359,37 +628,93 @@ html.css('img').each do |link| href = clean_link(link['src']) assets << href if href end - css = Dir.glob("#{options.public}/**/*.css").map { |path| path.gsub(options.public + '/', '') } + css = Dir.glob("#{settings.public_folder}/**/*.css").map { |path| path.gsub(settings.public_folder + '/', '') } assets << css - js = Dir.glob("#{options.public}/**/*.js").map { |path| path.gsub(options.public + '/', '') } + js = Dir.glob("#{settings.public_folder}/**/*.js").map { |path| path.gsub(settings.public_folder + '/', '') } assets << js assets.uniq.join("\n") end def slides(static=false) - get_slides_html(static) + get_slides_html(:static=>static) end def onepage(static=false) - @slides = get_slides_html(static) + @slides = get_slides_html(:static=>static, :toc=>true) + #@languages = @slides.scan(/<pre class=".*(?!sh_sourceCode)(sh_[\w-]+).*"/).uniq.map{ |w| "/sh_lang/#{w[0]}.min.js"} erb :onepage end + def print(static=false) + @slides = get_slides_html(:static=>static, :toc=>true, :print=>true) + erb :onepage + end + + def supplemental(content, static=false) + @slides = get_slides_html(:static=>static, :supplemental=>content) + @wrapper_classes = ['supplemental'] + erb :onepage + end + + def download() + begin + shared = Dir.glob("#{settings.pres_dir}/_files/share/*").map { |path| File.basename(path) } + # We use the icky -999 magic index because it has to be comparable for the view sort + @downloads = { -999 => [ true, 'Shared Files', shared ] } + rescue Errno::ENOENT => e + # don't fail if the directory doesn't exist + @downloads = {} + end + @downloads.merge! @@downloads + erb :download + end + + def stats() + if request.env['REMOTE_HOST'] == 'localhost' + # the presenter should have full stats + @counter = @@counter + end + + @all = Hash.new + @@counter.each do |slide, stats| + @all[slide] = 0 + stats.map { |host, count| @all[slide] += count } + end + + # most and least five viewed slides + @least = @all.sort_by {|slide, time| time}[0..4] + @most = @all.sort_by {|slide, time| -time}[0..4] + + erb :stats + end + def pdf(static=true) - @slides = get_slides_html(static, true) - @no_js = false + @slides = get_slides_html(:static=>static, :pdf=>true) + @inline = true + + # Identify which languages to bundle for highlighting + @languages = @slides.scan(/<pre class=".*(?!sh_sourceCode)(sh_[\w-]+).*"/).uniq.map{ |w| "/sh_lang/#{w[0]}.min.js"} + html = erb :onepage # TODO make a random filename + # Process inline css and js for included images + # The css uses relative paths for images and we prepend the file url + html.gsub!(/url\([\"\']?(?!https?:\/\/)(.*?)[\"\']?\)/) do |s| + "url(file://#{settings.pres_dir}/#{$1})" + end + + # Todo fix javascript path + # PDFKit.new takes the HTML and any options for wkhtmltopdf # run `wkhtmltopdf --extended-help` for a full list of options - kit = PDFKit.new(html, :page_size => 'Letter', :orientation => 'Landscape') + kit = PDFKit.new(html, ShowOffUtils.showoff_pdf_options(settings.pres_dir)) # Save the PDF to a file file = kit.to_file('/tmp/preso.pdf') end @@ -397,18 +722,20 @@ def self.do_static(what) what = "index" if !what - # Nasty hack to get the actual ShowOff module - showoff = ShowOff.new - while !showoff.is_a?(ShowOff) - showoff = showoff.instance_variable_get(:@app) - end + # Sinatra now aliases new to new! + # https://github.com/sinatra/sinatra/blob/v1.3.3/lib/sinatra/base.rb#L1369 + showoff = ShowOff.new! + name = showoff.instance_variable_get(:@pres_name) path = showoff.instance_variable_get(:@root_path) + logger = showoff.instance_variable_get(:@logger) + data = showoff.send(what, true) + if data.is_a?(File) FileUtils.cp(data.path, "#{name}.pdf") else out = File.expand_path("#{path}/static") # First make a directory @@ -431,29 +758,33 @@ } # Set up file dir file_dir = File.join(out, 'file') FileUtils.makedirs(file_dir) - pres_dir = showoff.options.pres_dir + pres_dir = showoff.settings.pres_dir # ..., copy all user-defined styles and javascript files Dir.glob("#{pres_dir}/*.{css,js}").each { |path| FileUtils.copy(path, File.join(file_dir, File.basename(path))) } # ... and copy all needed image files - data.scan(/img src=\".\/file\/(.*?)\"/).flatten.each do |path| - dir = File.dirname(path) - FileUtils.makedirs(File.join(file_dir, dir)) - FileUtils.copy(File.join(pres_dir, path), File.join(file_dir, path)) + [/img src=[\"\'].\/file\/(.*?)[\"\']/, /style=[\"\']background: url\(\'file\/(.*?)'/].each do |regex| + data.scan(regex).flatten.each do |path| + dir = File.dirname(path) + FileUtils.makedirs(File.join(file_dir, dir)) + FileUtils.copy(File.join(pres_dir, path), File.join(file_dir, path)) + end end # copy images from css too Dir.glob("#{pres_dir}/*.css").each do |css_path| File.open(css_path) do |file| data = file.read - data.scan(/url\((.*)\)/).flatten.each do |path| - @logger.debug path + data.scan(/url\([\"\']?(?!https?:\/\/)(.*?)[\"\']?\)/).flatten.each do |path| + path.gsub!(/(\#.*)$/, '') # get rid of the anchor + path.gsub!(/(\?.*)$/, '') # get rid of the query + logger.debug path dir = File.dirname(path) FileUtils.makedirs(File.join(file_dir, dir)) FileUtils.copy(File.join(pres_dir, path), File.join(file_dir, path)) end end @@ -465,31 +796,209 @@ eval(code).to_s rescue => e e.message end + # Basic auth boilerplate + def protected! + unless authorized? + response['WWW-Authenticate'] = %(Basic realm="#{@title}: Protected Area") + throw(:halt, [401, "Not authorized\n"]) + end + end + + def authorized? + if not settings.showoff_config.has_key? 'password' + # if no password is set, then default to allowing access to localhost + request.env['REMOTE_HOST'] == 'localhost' or request.ip == '127.0.0.1' + else + auth ||= Rack::Auth::Basic::Request.new(request.env) + user = settings.showoff_config['user'] || '' + password = settings.showoff_config['password'] + auth.provided? && auth.basic? && auth.credentials && auth.credentials == [user, password] + end + end + + def guid + # this is a terrifyingly simple GUID generator + (0..15).to_a.map{|a| rand(16).to_s(16)}.join + end + + def valid_cookie + (request.cookies['presenter'] == @@cookie) + end + get '/eval_ruby' do return eval_ruby(params[:code]) if ENV['SHOWOFF_EVAL_RUBY'] return "Ruby Evaluation is off. To turn it on set ENV['SHOWOFF_EVAL_RUBY']" end get %r{(?:image|file)/(.*)} do path = params[:captures].first - full_path = File.join(options.pres_dir, path) - send_file full_path + full_path = File.join(settings.pres_dir, path) + if File.exist?(full_path) + send_file full_path + else + raise Sinatra::NotFound + end end - get %r{/(.*)} do - @title = ShowOffUtils.showoff_title + get '/control' do + if !request.websocket? + raise Sinatra::NotFound + else + request.websocket do |ws| + ws.onopen do + ws.send( { 'current' => @@current[:number] }.to_json ) + settings.sockets << ws + + @logger.warn "Open sockets: #{settings.sockets.size}" + end + ws.onmessage do |data| + begin + control = JSON.parse(data) + + @logger.warn "#{control.inspect}" + + case control['message'] + when 'update' + # websockets don't use the same auth standards + # we use a session cookie to identify the presenter + if valid_cookie() + name = control['name'] + slide = control['slide'].to_i + + # check to see if we need to enable a download link + if @@downloads.has_key?(slide) + @logger.debug "Enabling file download for slide #{name}" + @@downloads[slide][0] = true + end + + # update the current slide pointer + @logger.debug "Updated current slide to #{name}" + @@current = { :name => name, :number => slide } + + # schedule a notification for all clients + EM.next_tick { settings.sockets.each{|s| s.send({ 'current' => @@current[:number] }.to_json) } } + end + + when 'register' + # save a list of presenters + if valid_cookie() + remote = request.env['REMOTE_HOST'] || request.env['REMOTE_ADDR'] + settings.presenters << ws + @logger.warn "Registered new presenter: #{remote}" + end + + when 'track' + remote = request.env['REMOTE_HOST'] || request.env['REMOTE_ADDR'] + slide = control['slide'] + time = control['time'].to_f + + @logger.debug "Logged #{time} on slide #{slide} for #{remote}" + + # a bucket for this slide + @@counter[slide] ||= Hash.new + # a bucket of slideviews for this address + @@counter[slide][remote] ||= Array.new + # and add this slide viewing to the bucket + @@counter[slide][remote] << { :elapsed => time, :timestamp => Time.now.to_i, :presenter => @@current[:name] } + + when 'position' + ws.send( { 'current' => @@current[:number] }.to_json ) unless @@cookie.nil? + + when 'pace', 'question' + # just forward to the presenter(s) + EM.next_tick { settings.presenters.each{|s| s.send(data) } } + + when 'feedback' + filename = "#{settings.statsdir}/#{settings.feedback}" + slide = control['slide'] + rating = control['rating'] + feedback = control['feedback'] + + begin + log = JSON.parse(File.read(filename)) + rescue + # do nothing + end + + log ||= Hash.new + log[slide] ||= Array.new + log[slide] << { :rating => rating, :feedback => feedback } + + if settings.verbose then + File.write(filename, JSON.pretty_generate(log)) + else + File.write(filename, log.to_json) + end + + else + @logger.warn "Unknown message <#{control['message']}> received." + @logger.warn control.inspect + end + + rescue Exception => e + @logger.warn "Messaging error: #{e}" + end + end + ws.onclose do + @logger.warn("websocket closed") + settings.sockets.delete(ws) + end + end + end + end + + # gawd, this whole routing scheme is bollocks + get %r{/([^/]*)/?([^/]*)} do + @title = ShowOffUtils.showoff_title(settings.pres_dir) + @pause_msg = ShowOffUtils.pause_msg what = params[:captures].first + opt = params[:captures][1] what = 'index' if "" == what - if (what != "favicon.ico") - data = send(what) - if data.is_a?(File) - send_file data.path + + if settings.showoff_config.has_key? 'protected' + protected! if settings.showoff_config['protected'].include? what + end + + # this hasn't been set to anything remotely interesting for a long time now + @asset_path = nil + + begin + if (what != "favicon.ico") + if what == 'supplemental' + data = send(what, opt) + else + data = send(what) + end + if data.is_a?(File) + send_file data.path + else + data + end + end + rescue NoMethodError => e + @logger.warn "Invalid object #{what} requested." + raise Sinatra::NotFound + end + end + + not_found do + # Why does the asset path start from cwd?? + @asset_path.slice!(/^./) + @env = request.env + erb :'404' + end + + at_exit do + if defined?(@@counter) + filename = "#{settings.statsdir}/#{settings.viewstats}" + if settings.verbose then + File.write(filename, JSON.pretty_generate(@@counter)) else - data + File.write(filename, @@counter.to_json) end end end end