| # |
| # 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 |