0pdd.rb
# Copyright (c) 2016-2024 Yegor Bugayenko
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the 'Software'), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
$stdout.sync = true
require 'mail'
require 'haml'
require 'json'
require 'ostruct'
require 'sinatra'
require 'sinatra/cookies'
require 'sass'
require 'rack'
require 'raven'
require 'octokit'
require 'tmpdir'
require 'glogin'
require 'uri'
require 'net/http'
require_relative 'version'
require_relative 'objects/log'
require_relative 'objects/dynamo'
require_relative 'objects/git_repo'
require_relative 'objects/user_error'
require_relative 'objects/vcs/github'
require_relative 'objects/vcs/gitlab'
require_relative 'objects/clients/github'
require_relative 'objects/clients/gitlab'
require_relative 'objects/jobs/job'
require_relative 'objects/jobs/job_detached'
require_relative 'objects/jobs/job_emailed'
require_relative 'objects/jobs/job_recorded'
require_relative 'objects/jobs/job_starred'
require_relative 'objects/jobs/job_commiterrors'
require_relative 'objects/tickets/tickets'
require_relative 'objects/tickets/tagged_tickets'
require_relative 'objects/tickets/emailed_tickets'
require_relative 'objects/tickets/logged_tickets'
require_relative 'objects/tickets/commit_tickets'
require_relative 'objects/tickets/sentry_tickets'
require_relative 'objects/tickets/milestone_tickets'
require_relative 'objects/storage/s3'
require_relative 'objects/storage/safe_storage'
require_relative 'objects/storage/sync_storage'
require_relative 'objects/storage/logged_storage'
require_relative 'objects/storage/versioned_storage'
require_relative 'objects/storage/upgraded_storage'
require_relative 'objects/storage/cached_storage'
require_relative 'objects/storage/once_storage'
require_relative 'objects/invitations/github_invitations'
require_relative 'test/fake_storage'
configure do
Haml::Options.defaults[:format] = :xhtml
config = if ENV['RACK_ENV'] == 'test'
{
'testing' => true,
'github' => {
'token' => '--the-token--',
'client_id' => '?',
'client_secret' => '?'
},
'gitlab' => {
'token' => '--the-token--',
'client_id' => '?',
'client_secret' => '?'
},
'jira' => {
'token' => '--the-token--',
'client_id' => '?',
'client_secret' => '?'
},
'sentry' => '',
's3' => {
'region' => '?',
'bucket' => '?',
'key' => '?',
'secret' => '?'
},
'id_rsa' => ''
}
else
config = YAML.safe_load(File.open(File.join(File.dirname(__FILE__), 'config.yml')))
raise 'Missing configuration file config.yml' if config.nil?
config
end
if ENV['RACK_ENV'] != 'test'
Raven.configure do |c|
c.dsn = config['sentry']
c.release = VERSION
end
end
set :config, config
if config['smtp']
Mail.defaults do
delivery_method(
:smtp,
address: config['smtp']['host'],
port: config['smtp']['port'],
user_name: config['smtp']['user'],
password: config['smtp']['password'],
domain: '0pdd.com',
enable_starttls_auto: true
)
end
end
set :server_settings, timeout: 25
set :github, Github.new(config).client
set :gitlab, GitlabClient.new(config).client
set :dynamo, Dynamo.new(config).aws
set :glogin, GLogin::Auth.new(
config['github']['client_id'],
config['github']['client_secret'],
'https://www.0pdd.com/github-callback'
)
set :ruby_version, Exec.new('ruby -e "print RUBY_VERSION"').run
set :git_version, Exec.new('git --version | cut -d" " -f 3').run
set :temp_dir, Dir.mktmpdir('0pdd')
if ENV['RACK_ENV'] != 'test'
Thread.new do
loop do
sleep(10)
Net::HTTP.get_response(URI('https://www.0pdd.com/ping-github'))
rescue Exception
# If we reach this point, we must not even try to
# do anything. Here we must quietly ignore everything
# and let the daemon go to the next cycle.
end
end
end
end
use Rack::Deflater
# @todo #572:1h rewind is removed from rack 3.0, so it is moved to
# rewindableInput for now, but it is better to check another solutions
use Rack::RewindableInput::Middleware
before '/*' do
@locals = {
ver: VERSION,
login_link: settings.glogin.login_uri
}
if cookies[:glogin]
begin
@locals[:user] = GLogin::Cookie::Closed.new(
cookies[:glogin],
settings.config['github']['encryption_secret']
).to_user
rescue OpenSSL::Cipher::CipherError
@locals.delete(:user)
end
end
end
get '/github-callback' do
code = params[:code]
redirect('/') if code.nil?
cookies[:glogin] = GLogin::Cookie::Open.new(
settings.glogin.user(code),
settings.config['github']['encryption_secret']
).to_s
redirect to('/')
end
get '/logout' do
cookies.delete(:glogin)
redirect to('/')
end
get '/' do
projects = Exec.new(
"(sort /tmp/0pdd-done.txt 2>/dev/null || echo '')\
| uniq"
).run.split("\n").reject(&:empty?)
haml :index, layout: :layout, locals: merged(
title: '0pdd',
ruby_version: settings.ruby_version,
git_version: settings.git_version,
remaining: settings.github.rate_limit.remaining,
tail: projects.last(10).reverse
)
end
get '/robots.txt' do
'User-agent: *
Disallow: /snapshot'
end
get '/version' do
VERSION
end
get '/invitation' do
repo = repo_name(params[:repo])
ghi = GithubInvitations.new(settings.github)
invitations = ghi.accept_single_invitation(repo)
return invitations.join('\n') unless invitations.empty?
"Could not find invitation for @#{repo}. It is either invitation already
accepted OR 0pdd is not added as a collaborator"
end
get '/p' do
vcs = vcs_name(params[:vcs])
name = repo_name(params[:name])
xml = storage(name, vcs).load
Nokogiri::XSLT(File.read('assets/xsl/puzzles.xsl')).transform(
xml,
[
'version', "'#{VERSION}'",
'project', "'#{name}'",
'length', xml.to_s.length.to_s
]
).to_s
end
get '/xml' do
content_type 'text/xml'
vcs = vcs_name(params[:vcs])
storage(repo_name(params[:name]), vcs).load.to_s
end
get '/log' do
vcs = vcs_name(params[:vcs])
repo = repo_name(params[:name])
haml :log, layout: :layout, locals: merged(
title: repo,
repo: repo,
log: Log.new(settings.dynamo, repo, vcs),
since: params[:since] ? params[:since].to_i : Time.now.to_i + 1
)
end
get '/snapshot' do
content_type 'text/xml'
master = params[:branch]
vcs = vcs_name(params[:vcs])
name = repo_name(params[:name])
uri = "git@github.com:#{name}.git"
uri = "git@gitlab.com:#{name}.git" if vcs == 'gitlab'
begin
repo = GitRepo.new(
uri: uri,
name: name,
id_rsa: settings.config['id_rsa'],
dir: settings.temp_dir,
master: master || 'master'
)
repo.push
xml = repo.xml
xml.xpath('//processing-instruction("xml-stylesheet")').remove
xml.to_s
rescue Exec::Error => e
error 400, "Could not get snapshot for #{name}: #{e.message}"
end
end
get '/log-item' do
vcs = vcs_name(params[:vcs])
repo = repo_name(params[:repo])
tag = params[:tag]
error 404 if tag.nil?
log = Log.new(settings.dynamo, repo, vcs)
error 404 unless log.exists(tag)
haml :item, layout: :layout, locals: merged(
title: tag,
repo: repo,
item: log.get(tag)
)
end
get '/log-delete' do
redirect '/' if @locals[:user].nil? || @locals[:user][:login] != 'yegor256'
repo = repo_name(params[:name])
vcs = vcs_name(params[:vcs])
Log.new(settings.dynamo, repo, vcs).delete(params[:time].to_i, params[:tag])
redirect "/log?name=#{repo}"
end
get '/svg' do
response.headers['Cache-Control'] = 'no-cache, private'
content_type 'image/svg+xml'
name = repo_name(params[:name])
vcs = vcs_name(params[:vcs])
Nokogiri::XSLT(File.read('assets/xsl/svg.xsl')).transform(
storage(name, vcs).load, ['project', "'#{name}'"]
).to_s
end
get '/ping-github' do
content_type 'text/plain'
gh = settings.github
return if gh.rate_limit.remaining < 1000
invitations = GithubInvitations.new(gh)
invitations.accept
invitations.accept_orgs
msgs = gh.notifications.map do |n|
reason = n['reason']
repo = n['repository']['full_name']
puts "GitHub notification in #{repo}: #{reason} #{n['updated_at']} #{n['subject']['type']}"
if reason == 'mention'
issue = n['subject']['url'].gsub(%r{^.+/issues/}, '').to_i
comment = n['subject']['latest_comment_url'].gsub(%r{^.+/comments/}, '').to_i
begin
json = gh.issue_comment(repo, comment)
body = json['body']
if body.start_with?("@#{gh.login}") && json['user']['login'] != gh.login
gh.add_comment(
repo,
issue,
"> #{body.gsub(/\s+/, ' ').gsub(/^(.{100,}?).*$/m, '\1...')}\n\n\
I see you're talking to me, but I can't reply since I'm not a chat bot."
)
puts "Replied to #{repo}##{issue}"
end
rescue Octokit::NotFound
next
end
end
gh.mark_notifications_as_read(last_read_at: n['last_read_at'])
"#{repo}: #{reason}"
end
"#{msgs.join("\n")}\n"
end
get '/hook/github' do
'This URL expects POST requests from GitHub
WebHook: https://developer.github.com/webhooks/'
end
post '/hook/github' do
is_from_github = request.env['HTTP_USER_AGENT']&.start_with?('GitHub-Hookshot')
is_push_event = request.env['HTTP_X_GITHUB_EVENT'] == 'push'
unless is_from_github && is_push_event
return [
400,
'Please, only register push events from GitHub webhook'
]
end
request.env['rack.input'].rewind if request.env['rack.input'].respond_to?(:rewind)
request.body.rewind unless request.env['rack.input'].respond_to?(:rewind)
json = JSON.parse(
case request.content_type
when 'application/x-www-form-urlencoded'
payload = params[:payload]
# see https://docs.github.com/en/webhooks/using-webhooks/creating-webhooks
if payload.nil?
return [
400,
'URL-encoded content is expected in the "payload" query parameter, but it is not provided'
]
end
payload
when 'application/json'
request.body.read
else
raise "Invalid content-type: \"#{request.content_type}\""
end
)
github = GithubRepo.new(settings.github, json, settings.config)
return [400, "No access to #{github.repo.name}"] unless github.exists?
unless ENV['RACK_ENV'] == 'test'
process_request(github) if github.repo.change_in_master?
puts "GitHub hook from #{github.repo.name} to branch #{github.repo.target}"
end
ignore = ''
ignore = 'Push is not to master branch, nothing is done. ' unless github.repo.change_in_master?
"#{ignore}Thanks #{github.repo.name}"
end
get '/hook/gitlab' do
'This URL expects POST requests from Gitlab
WebHook: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html'
end
post '/hook/gitlab' do
is_from_gitlab = request.env['HTTP_USER_AGENT'].start_with?('GitLab')
is_push_event = request.env['HTTP_X_GITLAB_EVENT'] == 'Push Hook'
unless is_from_gitlab && is_push_event
return [
400,
'Please, only register push events from Gitlab webhook'
]
end
request.env['rack.input'].rewind if request.env['rack.input'].respond_to?(:rewind)
request.body.rewind unless request.env['rack.input'].respond_to?(:rewind)
json = JSON.parse(
case request.content_type
when 'application/x-www-form-urlencoded'
params[:payload]
when 'application/json'
request.body.read
else
raise "Invalid content-type: \"#{request.content_type}\""
end
)
gitlab = GitlabRepo.new(settings.gitlab, json, settings.config)
return [400, "No access to #{gitlab.repo.name}"] unless gitlab.exists?
unless ENV['RACK_ENV'] == 'test'
process_request(gitlab) if gitlab.repo.change_in_master?
puts "Gitlab hook from #{gitlab.repo.name} to branch #{gitlab.repo.target}"
end
ignore = ''
ignore = 'Push is not to master branch, nothing is done. ' unless gitlab.repo.change_in_master?
"#{ignore}Thanks #{gitlab.repo.name}"
end
get '/css/*.css' do
content_type 'text/css', charset: 'utf-8'
file = params[:splat].first
template = File.join(File.absolute_path('./assets/sass/'), "#{file}.sass")
Sass::Engine.new(File.read(template)).render
end
get '/puzzles.xsd' do
content_type 'application/xml', charset: 'utf-8'
File.read('assets/xsd/puzzles.xsd')
end
not_found do
status 404
content_type 'text/html', charset: 'utf-8'
haml :not_found, layout: :layout, locals: merged(
title: 'Page not found'
)
end
error do
status 503
e = env['sinatra.error']
Raven.capture_exception(e) unless e.is_a?(UserError)
haml(
:error,
layout: :layout,
locals: merged(
title: 'error',
error: "#{e.message}\n\t#{e.backtrace.join("\n\t")}"
)
)
end
private
def repo_name(name)
error 404 if name.nil?
error 404 unless name =~ %r{^[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_.]+$}
name.strip
end
def vcs_name(name)
return 'github' if name.nil?
name.strip.downcase
end
def merged(hash)
out = @locals.merge(hash)
out[:local_assigns] = out
out
end
def storage(repo, vcs)
file_name = vcs == 'github' ? repo : "#{vcs}-#{repo}"
SyncStorage.new(
UpgradedStorage.new(
SafeStorage.new(
OnceStorage.new(
CachedStorage.new(
VersionedStorage.new(
if ENV['RACK_ENV'] == 'test'
FakeStorage.new
else
LoggedStorage.new(
S3.new(
"#{file_name}.xml",
settings.config['s3']['bucket'],
settings.config['s3']['region'],
settings.config['s3']['key'],
settings.config['s3']['secret']
),
Log.new(settings.dynamo, repo, vcs)
)
end,
VERSION
),
File.join('/tmp/0pdd-xml-cache', file_name)
)
)
),
VERSION
)
)
end
def process_request(vcs)
JobDetached.new(
vcs,
JobCommitErrors.new(
vcs,
JobEmailed.new(
vcs,
JobRecorded.new(
vcs,
JobStarred.new(
vcs,
Job.new(
vcs,
storage(vcs.repo.name, vcs.name),
SentryTickets.new(
EmailedTickets.new(
vcs,
CommitTickets.new(
vcs,
TaggedTickets.new(
vcs,
LoggedTickets.new(
vcs,
Log.new(settings.dynamo, vcs.repo.name, vcs.name),
MilestoneTickets.new(
vcs,
Tickets.new(vcs)
)
)
)
)
)
)
)
)
)
)
)
).proceed
end