www/secretary/workbench/server.rb (302 lines of code) (raw):

# # Simple web server that routes requests to views based on URLs. # require 'wunderbar/sinatra' require 'wunderbar/bootstrap' require 'wunderbar/vue' require 'ruby2js/es2017/strict' require 'ruby2js/filter/functions' require 'ruby2js/filter/require' require 'erb' require 'uri' require 'sanitize' require 'escape' require 'time' # for iso8601 require 'whimsy/asf/meeting-util' require_relative 'personalize' require_relative 'helpers' require_relative 'models/mailbox' require_relative 'models/events' require_relative 'tasks' # monkey patch mail gem to work around a regression introduced in 2.7.0: # https://github.com/mikel/mail/pull/1168 module Mail class Message def raw_source=(value) @raw_source = ::Mail::Utilities.to_crlf(value) end end module Utilities def self.safe_for_line_ending_conversion?(string) if RUBY_VERSION >= '1.9' string.ascii_only? or (string.encoding != Encoding::BINARY and string.valid_encoding?) else string.ascii_only? end end end end require 'whimsy/asf' require 'whimsy/asf/memapps' ASF::Mail.configure SECS_TO_DAYS = 60*60*24 disable :logging # suppress log of requests to stderr/error.log require 'whimsy/asf/status' error do err = env['sinatra.error'] <<~EOD <pre> Error detected, please see web server error log for full details: #{Time.now.gmtime.to_s}: #{err.detailed_message} </pre> EOD end # list of messages get '/' do redirect to('/') if env['REQUEST_URI'] == env['SCRIPT_NAME'] # determine latest month for which there are messages current = Date.today.strftime('%Y%m') # exclude future-dated entries archives = Dir[File.join(ARCHIVE, '*.yml')].select {|name| name =~ %r{/\d{6}\.yml$} && File.basename(name,'.yml') <= current} @mbox = archives.empty? ? nil : File.basename(archives.max, '.yml') if @mbox @messages = Mailbox.new(@mbox).client_headers.select do |message| message[:status] != :deleted end else @messages = [] # ensure the array exists end # Show outstanding emeritus requests ASF::EmeritusRequestFiles.listnames(true).each do |epoch, file| days = (((Time.now.to_i - epoch.to_i).to_f / SECS_TO_DAYS)).round(1) id = File.basename(file, '.*') @messages << { date: Time.at(epoch.to_i).gmtime.asctime, time: Time.at(epoch.to_i).gmtime.iso8601, href: "/roster/committer/#{id}", href2: ASF::SVN.svnpath!('emeritus-requests-received', file), from: ASF::Person.find(id).cn, subject: "Pending emeritus request - #{days} days old", status: days < 10.0 ? :emeritusPending : :emeritusReady } end # Show outstanding withdrawal requests ASF::WithdrawalRequestFiles.listnames(true, env).each do |epoch, file| days = (((Time.now.to_i - epoch.to_i).to_f / SECS_TO_DAYS)).round(1) id = File.basename(file, '.*') @messages << { date: Time.at(epoch.to_i).gmtime.asctime, time: Time.at(epoch.to_i).gmtime.iso8601, href: "/roster/committer/#{id}", href2: ASF::SVN.svnpath!('withdrawn-pending', file), from: ASF::Person.find(id).cn, subject: "Pending withdrawal request - #{days} days old", status: days < 10.0 ? :withdrawalPending : :withdrawalReady } end @cssmtime = File.mtime('public/secmail.css').to_i @appmtime = Wunderbar::Asset.convert(File.join(settings.views, 'app.js.rb')).mtime.to_i _html :index end # alias for root directory get '/index.html' do call env.merge('PATH_INFO' => '/') end # support for fetching previous month's worth of messages get %r{/(\d{6})} do |mbox| @mbox = mbox _json :index # This invokes workbench/views/index.json.rb end get '/deleted' do current = Mailbox.allmailboxes.last return [404, 'Not found'] unless current redirect to("/#{current}/deleted") end # display deleted messages get %r{/(\d{6})/deleted} do |mbox| @mbox = mbox @prv, @nxt = Mailbox.prev_next(mbox) @messages = Mailbox.new(@mbox).client_headers(listall: true).select do |message| message[:status] == :deleted end _html :deleted end get '/pending' do current = Mailbox.allmailboxes.last return [404, 'Not found'] unless current redirect to("/#{current}/pending") end # display pending messages get %r{/(\d{6})/pending} do |mbox| @mbox = mbox @prv, @nxt = Mailbox.prev_next(mbox) @messages = Mailbox.new(@mbox).client_headers.reject do |message| message[:status] == :deleted end _html :pending end get '/all' do current = Mailbox.allmailboxes.last return [404, 'Not found'] unless current redirect to("/#{current}/all") end # display all messages get %r{/(\d{6})/all} do |mbox| @mbox = mbox @prv, @nxt = Mailbox.prev_next(mbox) @messages = Mailbox.new(@mbox).client_headers(listall: true) _html :all end # retrieve a single message get %r{/(\d{6})/(\w+)/} do |month, hash| @message = Mailbox.new(month).headers[hash] pass unless @message _html :message end # task lists post '/tasklist/:file' do unavailable = Status.updates_disallowed_reason # are updates disallowed? return [503, unavailable] if unavailable @jsmtime = File.mtime('public/tasklist.js').to_i @cssmtime = File.mtime('public/secmail.css').to_i if request.content_type == 'application/json' _json(:"actions/#{params[:file]}") else @dryrun = JSON.parse(_json(:"actions/#{params[:file]}")) _html :tasklist end end # posted actions SAFE_ACTIONS = %w[check-mail check-signature] post '/actions/:file' do unavailable = Status.updates_disallowed_reason # are updates disallowed? return [503, unavailable] if unavailable && !SAFE_ACTIONS.include?(params[:file]) _json :"actions/#{params[:file]}" end # mark a single message as deleted delete %r{/(\d+)/(\w+)/} do |month, hash| unavailable = Status.updates_disallowed_reason # are updates disallowed? return [503, unavailable] if unavailable success = false Mailbox.update(month) do |headers| if headers[hash] headers[hash][:status] = :deleted success = true end end pass unless success _json success: true end # update a single message patch %r{/(\d{6})/(\w+)/} do |month, hash| unavailable = Status.updates_disallowed_reason # are updates disallowed? return [503, unavailable] if unavailable success = false Mailbox.update(month) do |headers| if headers[hash] updates = JSON.parse(request.env['rack.input'].read) # undelete attachments if requested attStatus = updates.delete('attachment_status') if attStatus headers[hash][:attachments]&.each do |att| att[:status] = nil end end # special processing for entries with symbols as keys headers[hash].each do |key, value| if key.is_a? Symbol and updates.has_key? key.to_s headers[hash][key] = updates.delete(key.to_s) end end headers[hash].merge! updates success = true end end pass unless success [204, {}, ''] end # list of parts for a single message get %r{/(\d{6})/(\w+)/_index_} do |month, hash| message = Mailbox.new(month).find(hash) return [404, 'Message Not Found'] unless message @attachments = message.attachments @headers = message.headers.dup @headers.delete :attachments @cssmtime = File.mtime('public/secmail.css').to_i @appmtime = Wunderbar::Asset.convert(File.join(settings.views, 'app.js.rb')).mtime.to_i @projects = (ASF::Podling.current+ASF::Committee.pmcs).map(&:name).sort # Check if applications closed; if so, check it application was received in time # Default to 'now' if envelope date not found; hopefully won't be triggered as emails # should now all have the field set up @meeting = ASF::MeetingUtil.applications_valid || ASF::MeetingUtil.application_valid?(@headers[:envelope_date] || DateTime.now.iso8601) _html :parts end # message body for a single message get %r{/(\d{6})/(\w+)/_body_} do |month, hash| @message = Mailbox.new(month).find(hash) return [404, 'Message Not Found'] unless @message @cssmtime = File.mtime('public/secmail.css').to_i @appmtime = Wunderbar::Asset.convert(File.join(settings.views, 'app.js.rb')).mtime.to_i _html :body end # header data for a single message get %r{/(\d{6})/(\w+)/_headers_} do |month, hash| @headers = Mailbox.new(month).headers[hash] pass unless @headers _html :headers end # raw data for a single message get %r{/(\d{6})/(\w+)/_raw_} do |month, hash| message = Mailbox.new(month).find(hash) pass unless message [200, {'Content-Type' => 'text/plain'}, message.raw] end # reparse an existing message get %r{/(\d{6})/(\w+)/_reparse_} do |month, hash| unavailable = Status.updates_disallowed_reason # are updates disallowed? return [503, unavailable] if unavailable mailbox = Mailbox.new(month) message = mailbox.find(hash) pass unless message email = message.raw headers = Message.parse(email) message = Message.new(mailbox, hash, headers, email) message.write_headers [200, {'Content-Type' => 'text/plain'}, 'Done, reselect to show contents'] end # intercede for potentially dangerous message attachments get %r{/(\d{6})/(\w+)/_danger_/(.*?)} do |month, hash, name| message = Mailbox.new(month).find(hash) pass unless message @part = message.find(URI::RFC2396_Parser.new.unescape(name)) pass unless @part _html :danger end # a specific attachment for a message get %r{/(\d{6})/(\w+)/(.*?)} do |month, hash, name| message = Mailbox.new(month).find(hash) pass unless message part = message.find(URI::RFC2396_Parser.new.unescape(name)) pass unless part [200, {'Content-Type' => part.content_type}, part.body.to_s] end # parse memapp-received get '/memapp.json' do _json :memapp end # return email for a given id get '/email.json' do _json do {email: ASF::Person.find(params[:id]).mail.first} end end # return a list of iclas get '/iclas.json' do list = [] ASF::ICLA.each do |icla| list << { filename: icla.claRef, id: icla.id, name: icla.name, fullname: icla.legal_name, email: icla.email } end list.to_json end # Return official list of members (not using LDAP) # includes inactive members get '/members.json' do list = ASF::Member.list.map { |k, v| {id: k, name: v[:name]}} _json list end # redirect to an icla get %r{/icla/(.*)} do |filename| checkout = ASF::SVN.svnurl('iclas') ASF::ICLAFiles.update_cache(env) file = ASF::ICLAFiles.match_claRef(filename) pass unless file redirect to(checkout + '/' + file) end # event stream for server sent events (a.k.a EventSource) get '/events', provides: 'text/event-stream' do events = Events.new stream :keep_open do |out| out.callback {events.close} loop do event = events.pop if event.is_a? Hash or event.is_a? Array out << "data: #{JSON.dump(event)}\n\n" elsif event == :heartbeat out << ":\n" elsif event == :exit out.close break else out << "data: #{event.inspect}\n\n" end end end end