blob: aecb99900aaf4b40b085a27a87af1fdf6407286c [file] [log] [blame]
#
# Server side Sinatra routes
#
require 'whimsy/asf/status'
UNAVAILABLE = Status.updates_disallowed_reason # are updates disallowed?
# redirect root to latest agenda
get '/' do
agenda = dir('board_agenda_*.txt').max
pass unless agenda
redirect "#{request.path}#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/"
end
# alias for latest agenda
get '/latest/' do
agenda = dir('board_agenda_*.txt').max
pass unless agenda
call env.merge(
'PATH_INFO' => "/#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/"
)
end
# alias for latest agenda in JSON format
get '/latest.json' do
agenda = dir('board_agenda_*.txt').max
pass unless agenda
call env.merge!(
'PATH_INFO' => "/#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}.json"
)
end
get '/calendar.json' do
_json do
{
nextMeeting: ASF::Board.nextMeeting.iso8601,
calendar: ASF::Board.calendar.map(&:iso8601),
agendas: dir('board_agenda_*.txt').sort,
drafts: dir('board_minutes_*.txt').sort
}
end
end
# icon
get '/whimsy.svg' do
send_file File.expand_path('../../../whimsy.svg', __FILE__),
type: 'image/svg+xml'
end
# Progress Web App Manfest
get '/manifest.json' do
@svgmtime = File.mtime(File.expand_path('../../../whimsy.svg', __FILE__)).to_i
@pngmtime = File.mtime(File.expand_path('../public/whimsy.png', __FILE__)).to_i
# capture all the variable content
hash = {
source: File.read("#{settings.views}/manifest.json.erb"),
svgmtime: @svgmtime
}
# detect if there were any modifications
etag Digest::MD5.hexdigest(JSON.dump(hash))
content_type 'application/json'
erb :"manifest.json"
end
# redirect shepherd to latest agenda
get '/shepherd' do
user = ASF::Person.find(env.user).public_name.split(' ').first
agenda = dir('board_agenda_*.txt').max
pass unless agenda
redirect File.dirname(request.path) +
"/#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/shepherd/#{user}"
end
# redirect missing to missing page for the latest agenda
get '/missing' do
agenda = dir('board_agenda_*.txt').max
pass unless agenda # this will result in a 404
# Support for sending out reminders before the agenda is created.
# Useful in cases where the agenda creation is delayed due to
# a board election.
if agenda < Date.today.strftime('board_agenda_%Y_%m_%d.txt')
# update in memory cache with a dummy agenda. The only relevant
# part of the agenda that matters for this operation is the list
# of pmcs (@pmcs).
template = File.join(ASF::SVN['foundation_board'], 'templates', 'board_agenda.erb')
@meeting = ASF::Board.nextMeeting
agenda = @meeting.strftime('board_agenda_%Y_%m_%d.txt')
@directors = ['TBD']
@minutes = []
@owner = ASF::Board::ShepherdStream.new
@pmcs = ASF::Board.reporting(@meeting)
contents = Erubis::Eruby.new(IO.read(template)).result(binding)
Agenda.update_cache(agenda, nil, contents, true)
end
response.headers['Location'] =
"#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/missing"
status 302
end
get '/session.json' do
_json do
{session: Session.user(env.user)}
end
end
# for debugging purposes
get '/env' do
content_type 'text/plain'
asset = {
path: Wunderbar::Asset.path,
root: Wunderbar::Asset.root,
virtual: Wunderbar::Asset.virtual,
scripts: Wunderbar::Asset.scripts.map do |script|
{path: script.path}
end
}
JSON.pretty_generate(env: env, ENV: ENV.to_h, asset: asset)
end
# enable debugging of the agenda cache
get '/cache.json' do
_json Agenda.cache
end
# agenda followup
get %r{/(\d\d\d\d-\d\d-\d\d)/followup\.json} do |date|
pass unless Dir.exist? '/srv/mail/board'
agenda = "board_agenda_#{date.gsub('-', '_')}.txt"
pass unless Agenda.parse agenda, :quick
# select agenda items that have comments
parsed = Agenda[agenda][:parsed]
followup = parsed.reject {|item| item['comments'].to_s.empty?}.
map {|item| [item['title'], {comments: item['comments'],
shepherd: item['shepherd'],
mail_list: item['mail_list'],
count: 0}]
}.to_h
# count number of feedback emails found in the board archive
start = Time.parse(date)
months = Dir['/srv/mail/board/*'].sort[-2..-1]
Dir[*months.map {|month| "#{month}/*"}].each do |file|
next unless File.mtime(file) > start
raw = File.read(file).force_encoding(Encoding::BINARY)
next unless raw =~ /Subject: .*Board feedback on #{date} (.*) report/
followup[$1][:count] += 1 if followup[$1]
end
# return results
_json followup
end
# pending items
get %r{/(\d\d\d\d-\d\d-\d\d)/pending\.json} do
pending = Pending.get(env.user)
_json pending
end
# agenda digest information
get %r{/(\d\d\d\d-\d\d-\d\d)/digest\.json} do |date|
agenda = "board_agenda_#{date.gsub('-', '_')}.txt"
_json(
{
agenda: {
file: agenda,
digest: Agenda[agenda][:digest],
etag: Agenda.uptodate(agenda) ? Agenda[agenda][:etag] : nil
},
reporter: Reporter.digest
}
)
end
# feedback
get %r{/(\d\d\d\d-\d\d-\d\d)/feedback.json} do |date|
@agenda = "board_agenda_#{date.gsub('-', '_')}.txt"
@dryrun = true
_json :'actions/feedback'
end
post %r{/(\d\d\d\d-\d\d-\d\d)/feedback.json} do |date|
return [503, UNAVAILABLE] if UNAVAILABLE
@agenda = "board_agenda_#{date.gsub('-', '_')}.txt"
@dryrun = false
_json :'actions/feedback'
end
def server
if env['REMOTE_USER']
userid = env['REMOTE_USER']
elsif ENV['RACK_ENV'] == 'test'
userid = env['HTTP_REMOTE_USER'] || 'test'
elsif env.respond_to? :user
userid = env.user
else
require 'etc'
userid = Etc.getlogin
end
pending = Pending.get(userid)
# determine who is present
@present = []
@present_mtime = nil
file = File.join(AGENDA_WORK, 'sessions', 'present.yml')
if File.exist?(file) and File.mtime(file) != @present_mtime
@present_mtime = File.mtime(file)
@present = YAML.load_file(file).
reject {|name| name =~ /^board_agenda_[_\d]+$/}
end
if env['SERVER_NAME'] == 'localhost'
websocket = 'ws://localhost:34234/'
else
websocket = (env['rack.url_scheme'].sub('http', 'ws')) + '://' +
env['SERVER_NAME'] + env['SCRIPT_NAME'] + '/websocket/'
end
@server = {
userid: userid,
agendas: dir('board_agenda_*.txt').sort,
drafts: dir('board_minutes_*.txt').sort,
pending: pending,
username: pending['username'],
firstname: pending['firstname'],
initials: pending['initials'],
online: @present,
session: Session.user(userid),
role: pending['role'],
directors: Hash[ASF::Service['board'].members.map {|person|
initials = begin
YAML.load_file(File.join(AGENDA_WORK, "#{person.id}.yml"))['initials']
rescue
person.public_name.gsub(/[^A-Z]/, '').downcase
end
[initials, person.public_name.split(' ').first]
}],
websocket: websocket
}
end
get '/server.json' do
_json server
end
# all agenda pages
get %r{/(\d\d\d\d-\d\d-\d\d)/(.*)} do |date, path|
agenda = "board_agenda_#{date.gsub('-', '_')}.txt"
pass unless Agenda.parse agenda, :quick
@base = "#{env['SCRIPT_NAME']}/#{date}/"
@server = server
@page = {
path: path,
query: params['q'],
agenda: agenda,
parsed: Agenda[agenda][:parsed],
digest: Agenda[agenda][:digest],
etag: Agenda.uptodate(agenda) ? Agenda[agenda][:etag] : nil
}
minutes = AGENDA_WORK + '/' +
agenda.sub('agenda', 'minutes').sub('.txt', '.yml')
@page[:minutes] = YAML.safe_load(File.read(minutes), permitted_classes: [Symbol]) if File.exist? minutes
@cssmtime = File.mtime('public/stylesheets/app.css').to_i
@manmtime = File.mtime("#{settings.views}/manifest.json.erb").to_i
@appmtime = Wunderbar::Asset.convert("#{settings.views}/app.js.rb").mtime.to_i
@server[:swmtime] = File.mtime("#{settings.views}/sw.js.rb").to_i
if path == 'bootstrap.html'
unless env.password
@server[:userid] = nil
@server[:role] = nil
end
@page[:parsed] = [
{title: 'Roll Call', timestamp: @page[:parsed].first['timestamp']}
]
@page[:digest] = nil
@page[:etag] = nil
@server[:session] = nil
# capture all the variable content
hash = {
source: File.read("#{settings.views}/bootstrap.html.erb"),
cssmtime: @cssmtime,
appmtime: @appmtime,
manmtime: @manmtime,
scripts: Wunderbar::Asset.scripts.
map {|script| [script.path, script.mtime.to_i]}.sort,
stylesheets: Wunderbar::Asset.stylesheets.
map {|stylesheet| [stylesheet.path, stylesheet.mtime.to_i]}.sort,
server: @server,
page: @page
}
# detect if there were any modifications
etag Digest::MD5.hexdigest(JSON.dump(hash))
erb :"bootstrap.html"
else
_html :main
end
end
# append slash to agenda page if not present
get %r{/(\d\d\d\d-\d\d-\d\d)} do |date|
redirect to("/#{date}/")
end
# post item support
get '/json/post-data' do
_json :"actions/post-data"
end
# feedback responses
get '/json/responses' do
_json :"actions/responses"
end
# posted reports
get '/json/posted-reports' do
_json :"actions/posted-reports"
end
post '/json/posted-reports' do
return [503, UNAVAILABLE] if UNAVAILABLE
_json :"actions/posted-reports"
end
# podling name searches
get '/json/podlingnamesearch' do
_json ASF::Podling.namesearch
end
# podling name searches
get '/json/reporter' do
_json Reporter.drafts(env)
end
# posted actions
post '/json/:file' do
return [503, UNAVAILABLE] if UNAVAILABLE
_json :"actions/#{params[:file]}"
end
# Raw minutes
get %r{/(\d\d\d\d-\d\d-\d\d).ya?ml} do |file|
minutes = AGENDA_WORK + '/' + "board_minutes_#{file.gsub('-', '_')}.yml"
pass unless File.exist? minutes
_text File.read minutes
end
# updates to agenda data
get %r{/(\d\d\d\d-\d\d-\d\d).json} do |date|
file = "board_agenda_#{date.gsub('-', '_')}.txt"
pass unless Agenda.parse file, :full
begin
_json do
last_modified Agenda[file][:mtime]
minutes_file = AGENDA_WORK + '/' + file.sub('_agenda_', '_minutes_').
sub('.txt', '.yml')
# merge in minutes, if available
if File.exist? minutes_file
minutes = YAML.load_file(minutes_file)
Agenda[file][:parsed].each do |item|
item[:minutes] = minutes[item['title']] if minutes[item['title']]
end
end
agenda = Agenda[file][:parsed]
# filter list for non-PMC chairs and non-officers
user = env.respond_to?(:user) && ASF::Person.find(env.user)
unless !user or user.asf_chair_or_member?
status 206 # Partial Content
committees = user.committees.map(&:display_name)
agenda = agenda.select {|item| committees.include? item['title']}
end
agenda
end
ensure
Agenda[file][:etag] = headers['ETag']
end
end
# draft committers report
get %r{/text/summary/(\d\d\d\d-\d\d-\d\d)} do |date|
@date = date.gsub('-', '_')
_text :committers_report
end
# draft minutes
get '/text/minutes/:file' do |file|
file = "board_minutes_#{file.gsub('-', '_')}.txt"
if dir('board_minutes_*.txt').include? file
path = File.join(FOUNDATION_BOARD, file)
elsif not Dir[File.join(ASF::SVN['minutes'], file[/\d+/], file)].empty?
path = File.join(ASF::SVN['minutes'], file[/\d+/], file)
else
pass
end
_text do
last_modified File.mtime(path)
_ File.read(path)
end
end
# jira project info
get '/json/jira' do
uri = URI.parse('https://issues.apache.org/jira/rest/api/2/project')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
request = Net::HTTP::Get.new(uri.request_uri)
response = http.request(request)
_json { JSON.parse(response.body).map {|project| project['key']} }
end
# get list of committers (for use in roll-call)
get '/json/committers' do
_json do
members = ASF.search_one(ASF::Group.base, "cn=member", 'memberUid').first
members = Hash[members.map {|name| [name, true]}]
ASF.search_one(ASF::Person.base, 'uid=*', ['uid', 'cn']).
map {|person| {id: person['uid'].first,
member: members[person['uid'].first] || false,
name: person['cn'].first.force_encoding('utf-8')}}.
sort_by {|person| person[:name].downcase.unicode_normalize(:nfd)}
end
end
# Secretary post-meeting todos
get '/json/secretary-todos/:date' do
return [503, UNAVAILABLE] if UNAVAILABLE
_json :'actions/todos'
end
post '/json/secretary-todos/:date' do
return [503, UNAVAILABLE] if UNAVAILABLE
_json :'actions/todos'
end
# potential actions
get '/json/potential-actions' do
_json :'actions/potential-actions'
end
get %r{/json/(reminder[12]|non-responsive)} do |reminder|
@reminder = reminder
_json :'actions/reminder-text'
end
# chat log
get %r{/json/chat/(\d\d\d\d_\d\d_\d\d)} do |date|
log = "#{AGENDA_WORK}/board_agenda_#{date}-chat.yml"
if File.exist? log
_json YAML.safe_load(File.read(log), permitted_classes: [Symbol])
else
_json []
end
end
# historical comments, filtered to only include the list of projects which
# the user is a member of the PMC for non-ASF-members and non-officers.
get '/json/historical-comments' do
user = env.respond_to?(:user) && ASF::Person.find(env.user)
comments = HistoricalComments.comments
unless !user or user.asf_chair_or_member?
status 206 # Partial Content
committees = user.committees.map(&:display_name)
comments = comments.select do |project, _list|
committees.include? project
end
end
_json comments.to_h
end
# draft minutes
get '/text/draft/:file' do |file|
agenda = "board_agenda_#{file.gsub('-', '_')}.txt"
minutes = AGENDA_WORK + '/' +
agenda.sub('_agenda_', '_minutes_').sub('.txt', '.yml')
_text do
Dir.chdir(FOUNDATION_BOARD) do
if Dir['board_agenda_*.txt'].include?(agenda)
_ Minutes.draft(agenda, minutes)
else
halt 404
end
end
end
end
# draft new agenda
get '/new' do
# extract time and date for next meeting, month of previous meeting
@meeting = ASF::Board.nextMeeting
localtime = ASF::Board::TIMEZONE.utc_to_local(@meeting)
@tzlink = ASF::Board.tzlink(localtime)
zone = ASF::Board::TIMEZONE.name
@start_time = localtime.strftime('%H:%M') + ' ' + zone
duration = 1.hours
@adjournment = (localtime + duration).strftime('%H:%M') + ' ' + zone
@prev_month = @meeting.to_date.prev_month.strftime('%B')
# retrieve latest committee info
# TODO: this is the workspace copy -- should it be using the copy from SVN instead?
cinfo = File.join(ASF::SVN['board'], 'committee-info.txt')
info = ASF::SVN.getInfo(cinfo, env.user, env.password)
contents = ASF::SVN.svn('cat', cinfo, {env: env})
ASF::Committee.load_committee_info(contents, info)
# extract committees expected to report 'next month'
next_month = contents[/Next month.*?\n\n/m].chomp
@next_month = next_month[/(.*#.*\n)+/] || ''
# get potential actions
begin
actions = JSON.parse(Wunderbar::JsonBuilder.new({}).instance_eval(
File.read("#{settings.views}/actions/potential-actions.json.rb"),
).target!, symbolize_names: true)[:actions]
rescue IOError => e
Wunderbar.warn "#{e}, could not access previous actions, continuing"
actions = nil
end
# Get directors, list of pmcs due to report, and shepherds
@directors = ASF::Board.directors
@pmcs = ASF::Board.reporting(@meeting)
@owner = ASF::Board::ShepherdStream.new(actions)
# Get list of unpublished and unapproved minutes (used by the agenda template)
latest = Dir["#{AGENDA_WORK}/board_minutes*.yml"].max
if latest
draft = YAML.load_file(latest)
else
draft = {} # allow for missing yml file
end
@minutes = dir("board_agenda_*.txt").
map {|file| Date.parse(file[/\d[_\d]+/].gsub('_', '-'))}.
reject {|date| date >= @meeting.to_date}.
reject {|date| draft[date.strftime('%B %d, %Y')] == 'approved'}.
sort
template = File.join(ASF::SVN['foundation_board'], 'templates', 'board_agenda.erb')
@disabled = dir("board_agenda_*.txt").
include? @meeting.strftime("board_agenda_%Y_%m_%d.txt")
begin
@agenda = Erubis::Eruby.new(IO.read(template)).result(binding)
rescue => error
status 500
STDERR.puts error
return "error in #{template} in: #{error}"
end
@cssmtime = File.mtime('public/stylesheets/app.css').to_i
_html :new
end
# post a new agenda
post %r{/(\d\d\d\d-\d\d-\d\d)/} do |date|
return [503, UNAVAILABLE] if UNAVAILABLE
boardurl = ASF::SVN.svnurl('foundation_board')
agenda = "board_agenda_#{date.gsub('-', '_')}.txt"
contents = params[:agenda].gsub("\r\n", "\n")
Dir.mktmpdir do |dir|
ASF::SVN.svn!('checkout', [boardurl, dir], {depth: 'empty', env: env})
agendapath = File.join(dir, agenda)
File.write agendapath, contents
ASF::SVN.svn!('add', agendapath)
currentpath = File.join(dir, 'current.txt')
ASF::SVN.svn!('update', currentpath, {env: env})
if File.symlink? currentpath # Does the symlink exist?
File.unlink currentpath
File.symlink agenda, currentpath
else
Wunderbar.warn "current.txt link does not exist, creating it"
File.symlink agenda, currentpath
ASF::SVN.svn!('add', currentpath)
end
ASF::SVN.svn!('commit', [agendapath, currentpath], {msg: "Post #{date} agenda", env: env})
Agenda.update_cache agenda, agendapath, contents, false
end
auto_remind(date, agenda)
redirect to("/#{date}/")
end
get %r{/testautoremind/(\d\d\d\d-\d\d-\d\d)/} do |date|
agenda = "board_agenda_#{date.gsub('-', '_')}.txt"
@dryrun = !Status.testnode? # For debug only!
_json auto_remind(date, agenda)
end
# Code to send initial reminders
def auto_remind(date, agenda)
# draft reminder text
@reminder = 'reminder1' # reminder template
@tzlink = ASF::Board.tzlink(ASF::Board::TIMEZONE.utc_to_local(ASF::Board.nextMeeting))
reminder = eval(File.read("views/actions/reminder-text.json.rb"))
# extract data
@subject = reminder[:subject]
@message = reminder[:body]
# send reminders and summary
@summary = 'reminder-summary' # template name
@sendsummary = true
@agenda = agenda
@meeting = date
boardchair = ASF::Committee.officers.select{|h| h.name == 'boardchair'}.first.chairs.first[:name]
@from = "\"#{boardchair}\" <board-chair@apache.org>"
eval(File.read("views/actions/send-reminders.json.rb"))
end