Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • depau/discourse-saml
  • jackv/discourse-saml
2 results
Show changes
Commits on Source (57)
Showing
with 636 additions and 555 deletions
2.8.0.beta9: 851f6cebe3fdd48019660b236a447abb6ddf9c89
2.8.0.beta8: 8002759dacc3c71ecf06fd33f112c84b14b6a59a
2.8.0.beta5: c08ae1e0d301a5f0d264930fa068cdbeb5735ecd
2.7.8: c08ae1e0d301a5f0d264930fa068cdbeb5735ecd
{
"extends": "eslint-config-discourse"
}
name: Discourse Plugin
on:
push:
branches:
- main
pull_request:
jobs:
ci:
uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1
auto_generated
gems
.DS_Store
node_modules
/gems
/auto_generated
{}
require:
- rubocop-discourse
- rubocop-rspec
AllCops:
TargetRubyVersion: 2.6
DisabledByDefault: true
Exclude:
- "db/schema.rb"
- "bundle/**/*"
- "vendor/**/*"
- "node_modules/**/*"
- "public/**/*"
- "plugins/**/gems/**/*"
Discourse:
Enabled: true
Discourse/NoChdir:
Exclude:
- 'spec/**/*' # Specs are run sequentially, so chdir can be used
- 'plugins/*/spec/**/*'
# Prefer &&/|| over and/or.
Style/AndOr:
Enabled: true
Style/FrozenStringLiteralComment:
Enabled: true
# Align `when` with `case`.
Layout/CaseIndentation:
Enabled: true
# Align comments with method definitions.
Layout/CommentIndentation:
Enabled: true
# No extra empty lines.
Layout/EmptyLines:
Enabled: true
# Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }.
Style/HashSyntax:
Enabled: true
# Two spaces, no tabs (for indentation).
Layout/IndentationWidth:
Enabled: true
Layout/SpaceAfterColon:
Enabled: true
Layout/SpaceAfterComma:
Enabled: true
Layout/SpaceAroundEqualsInParameterDefault:
Enabled: true
Layout/SpaceAroundKeyword:
Enabled: true
Layout/SpaceAroundOperators:
Enabled: true
Layout/SpaceBeforeFirstArg:
Enabled: true
# Defining a method with parameters needs parentheses.
Style/MethodDefParentheses:
Enabled: true
# Use `foo {}` not `foo{}`.
Layout/SpaceBeforeBlockBraces:
Enabled: true
# Use `foo { bar }` not `foo {bar}`.
Layout/SpaceInsideBlockBraces:
Enabled: true
# Use `{ a: 1 }` not `{a:1}`.
Layout/SpaceInsideHashLiteralBraces:
Enabled: true
Layout/SpaceInsideParens:
Enabled: true
# Detect hard tabs, no hard tabs.
Layout/IndentationStyle:
Enabled: true
# Blank lines should not have any spaces.
Layout/TrailingEmptyLines:
Enabled: true
# No trailing whitespace.
Layout/TrailingWhitespace:
Enabled: true
Lint/Debugger:
Enabled: true
Layout/BlockAlignment:
Enabled: true
# Align `end` with the matching keyword or starting expression except for
# assignments, where it should be aligned with the LHS.
Layout/EndAlignment:
Enabled: true
EnforcedStyleAlignWith: variable
# Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg.
Lint/RequireParentheses:
Enabled: true
Lint/ShadowingOuterLocalVariable:
Enabled: true
Layout/MultilineMethodCallIndentation:
Enabled: true
EnforcedStyle: indented
Layout/HashAlignment:
Enabled: true
Bundler/OrderedGems:
Enabled: false
Style/SingleLineMethods:
Enabled: true
Style/Semicolon:
Enabled: true
AllowAsExpressionSeparator: true
Style/RedundantReturn:
Enabled: true
Style/GlobalVars:
Enabled: true
Severity: warning
Exclude:
- 'lib/tasks/**/*'
- 'script/**/*'
- 'spec/**/*.rb'
- 'plugins/*/spec/**/*'
# Specs
RSpec/AnyInstance:
Enabled: false # To be decided
RSpec/AroundBlock:
Enabled: true
RSpec/BeforeAfterAll:
Enabled: false # To be decided
RSpec/ContextMethod:
Enabled: false # TODO
RSpec/ContextWording:
Enabled: false # To be decided
RSpec/DescribeClass:
Enabled: false # To be decided
RSpec/DescribeMethod:
Enabled: true
RSpec/DescribeSymbol:
Enabled: false # To be decided
RSpec/DescribedClass:
Enabled: false # To be decided
RSpec/DescribedClassModuleWrapping:
Enabled: false # To be decided
RSpec/EmptyExampleGroup:
Enabled: true
RSpec/EmptyLineAfterExample:
Enabled: false # TODO
RSpec/EmptyLineAfterExampleGroup:
Enabled: false # TODO
RSpec/EmptyLineAfterFinalLet:
Enabled: false # TODO
RSpec/EmptyLineAfterHook:
Enabled: false # TODO
RSpec/EmptyLineAfterSubject:
Enabled: false # TODO
RSpec/ExampleLength:
Enabled: false # To be decided
RSpec/ExampleWithoutDescription:
Enabled: true
RSpec/ExampleWording:
Enabled: false # TODO
RSpec/ExpectActual:
Enabled: true
RSpec/ExpectChange:
Enabled: false # To be decided
RSpec/ExpectInHook:
Enabled: false # To be decided
RSpec/ExpectOutput:
Enabled: true
RSpec/FilePath:
Enabled: false # To be decided
RSpec/Focus:
Enabled: true
RSpec/HookArgument:
Enabled: false # TODO
RSpec/HooksBeforeExamples:
Enabled: false # TODO
RSpec/ImplicitBlockExpectation:
Enabled: true
RSpec/ImplicitExpect:
Enabled: false # To be decided
RSpec/ImplicitSubject:
Enabled: false # To be decided
RSpec/InstanceSpy:
Enabled: true
RSpec/InstanceVariable:
Enabled: false # TODO
RSpec/InvalidPredicateMatcher:
Enabled: true
RSpec/ItBehavesLike:
Enabled: true
RSpec/IteratedExpectation:
Enabled: false # To be decided
RSpec/LeadingSubject:
Enabled: false # TODO
RSpec/LeakyConstantDeclaration:
Enabled: false # To be decided
RSpec/LetBeforeExamples:
Enabled: false # TODO
RSpec/LetSetup:
Enabled: false # TODO
RSpec/MessageChain:
Enabled: true
RSpec/MessageSpies:
Enabled: true
RSpec/MissingExampleGroupArgument:
Enabled: true
RSpec/MultipleDescribes:
Enabled: false # TODO
RSpec/MultipleSubjects:
Enabled: true
RSpec/NamedSubject:
Enabled: false # To be decided
RSpec/NestedGroups:
Enabled: false # To be decided
RSpec/OverwritingSetup:
Enabled: true
RSpec/ReceiveCounts:
Enabled: true
RSpec/ReceiveNever:
Enabled: true
RSpec/RepeatedDescription:
Enabled: false # TODO
RSpec/RepeatedExample:
Enabled: false # TODO
RSpec/RepeatedExampleGroupBody:
Enabled: false # TODO
RSpec/RepeatedExampleGroupDescription:
Enabled: false # TODO
RSpec/ReturnFromStub:
Enabled: true
RSpec/ScatteredSetup:
Enabled: false # TODO
RSpec/SharedContext:
Enabled: true
RSpec/SharedExamples:
Enabled: true
RSpec/SingleArgumentMessageChain:
Enabled: true
RSpec/SubjectStub:
Enabled: true
RSpec/UnspecifiedException:
Enabled: true
RSpec/VerifiedDoubles:
Enabled: true
RSpec/VoidExpect:
Enabled: true
RSpec/Yield:
Enabled: true
Capybara/CurrentPathExpectation:
Enabled: true
Capybara/FeatureMethods:
Enabled: true
FactoryBot/AttributeDefinedStatically:
Enabled: true
FactoryBot/CreateList:
Enabled: true
FactoryBot/FactoryClassName:
Enabled: true
Rails/HttpStatus:
Enabled: true
inherit_gem:
rubocop-discourse: stree-compat.yml
--print-width=100
--plugins=plugin/trailing_comma
module.exports = {
plugins: ["ember-template-lint-plugin-discourse"],
extends: "discourse:recommended",
};
# frozen_string_literal: true
source "https://rubygems.org"
group :development do
gem "rubocop-discourse"
gem "syntax_tree"
end
GEM
remote: https://rubygems.org/
specs:
ast (2.4.2)
json (2.6.2)
parallel (1.22.1)
parser (3.1.2.1)
ast (~> 2.4.1)
prettier_print (1.2.0)
rainbow (3.1.1)
regexp_parser (2.6.0)
rexml (3.2.5)
rubocop (1.36.0)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.2.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.20.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.21.0)
parser (>= 3.1.1.0)
rubocop-discourse (3.0)
rubocop (>= 1.1.0)
rubocop-rspec (>= 2.0.0)
rubocop-rspec (2.13.2)
rubocop (~> 1.33)
ruby-progressbar (1.11.0)
syntax_tree (5.1.0)
prettier_print (>= 1.2.0)
unicode-display_width (2.3.0)
PLATFORMS
arm64-darwin-20
ruby
x86_64-darwin-18
x86_64-darwin-19
x86_64-darwin-20
x86_64-linux
DEPENDENCIES
rubocop-discourse
syntax_tree
BUNDLED WITH
2.3.10
......@@ -18,4 +18,3 @@ 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.
......@@ -39,9 +39,11 @@ Add the following settings to your `discourse.conf` file:
- `saml_target_url`
### Group sync
- `DISCOURSE_SAML_SYNC_GROUPS`: Sync groups. Defaults to false.
- `DISCOURSE_SAML_GROUPS_ATTRIBUTE`: SAML attribute to use for group sync. Defaults to `memberOf`
- `DISCOURSE_SAML_GROUPS_FULLSYNC`: Should the assigned groups be completely synced including adding AND removing groups based on the IDP? Defaults to false. If set to true, `DISCOURSE_SAML_SYNC_GROUPS_LIST` and SAML attribute `groups_to_add`/`groups_to_remove` are not used.
- `DISCOURSE_SAML_GROUPS_LDAP_LEAFCN`: If your IDP transmits `cn=groupname,cn=groups,dc=example,dc=com` you can set this to true to use only `groupname`. This is useful if you want to keep the standard group name length of Discourse (20 characters).
- `DISCOURSE_SAML_SYNC_GROUPS_LIST`: Groups mentioned in this list are synced if they are referenced by the IDP (in `memberOf` SAML attribue). Any other groups will not be removed/updated.
### Other Supported settings
......@@ -50,27 +52,43 @@ Add the following settings to your `discourse.conf` file:
- `DISCOURSE_SAML_SP_PRIVATE_KEY`: SAML Service Provider Private Key
- `DISCOURSE_SAML_AUTHN_REQUESTS_SIGNED`: defaults to false
- `DISCOURSE_SAML_WANT_ASSERTIONS_SIGNED`: defaults to false
- `DISCOURSE_SAML_LOGOUT_REQUESTS_SIGNED`: defaults to false
- `DISCOURSE_SAML_LOGOUT_RESPONSES_SIGNED`: defaults to false
- `DISCOURSE_SAML_NAME_IDENTIFIER_FORMAT`: defaults to "urn:oasis:names:tc:SAML:2.0:protocol"
- `DISCOURSE_SAML_DEFAULT_EMAILS_VALID`: defaults to true
- `DISCOURSE_SAML_VALIDATE_EMAIL_FIELDS`: defaults to blank. This setting accepts pipe separated group names that are supplied in `memberOf` attribute in SAML payload. If the group name specified in the value matches that from `memberOf` attribute than the `email_valid` is set to `true`, otherwise it defaults to `false`. This setting overrides `DISCOURSE_SAML_DEFAULT_EMAILS_VALID`.
- `DISCOURSE_SAML_BUTTON_TITLE`: 'with SAML'
- `DISCOURSE_SAML_TITLE`: 'SAML'
- `DISCOURSE_SAML_MESSAGE`: "Authorizing with #{title} (make sure pop up blockers are not enabled)"
- `DISCOURSE_SAML_FRAME_WIDTH`: '600'
- `DISCOURSE_SAML_FRAME_HEIGHT`: '400'
- `DISCOURSE_SAML_FULL_SCREEN_LOGIN`: false
- `DISCOURSE_SAML_SYNC_MODERATOR`: defaults to false. If set to `true` user get moderator role if SAML attribute `isModerator` (or attribute specified by `DISCOURSE_SAML_MODERATOR_ATTRIBUTE`) is 1 or true.
- `DISCOURSE_SAML_SYNC_MODERATOR`: defaults to false. If set to `true` user get moderator role if SAML attribute `isModerator` (or attribute specified by `DISCOURSE_SAML_MODERATOR_ATTRIBUTE`) is 1 or true.
- `DISCOURSE_SAML_MODERATOR_ATTRIBUTE`: defaults to `isModerator`
- `DISCOURSE_SAML_SYNC_ADMIN`: defaults to false. If set to `true` user get admin role if SAML attribute `isAdmin` (or attribute specified by `DISCOURSE_SAML_ADMIN_ATTRIBUTE`) is 1 or true.
- `DISCOURSE_SAML_ADMIN_ATTRIBUTE`: defaults to `isAdmin`
- `DISCOURSE_SAML_SYNC_TRUST_LEVEL`: defaults to false. If set to `true` user's trust level is set to the SAML attribute `trustLevel` (or attribute specified by `DISCOURSE_SAML_TRUST_LEVEL_ATTRIBUTE`) which needs to be between 1 and 4.
- `DISCOURSE_SAML_TRUST_LEVEL_ATTRIBUTE`: defaults to `trustLevel`
### Converting an RSA Key to a PEM
If the idp has an RSA key split up as modulus and exponent, this javascript library makes it easy to convert to pem:
https://www.npmjs.com/package/rsa-pem-from-mod-exp
### Moving from environment variables to Site Settings
With the Environment variables set, run this snippet in the rails console:
```ruby
SiteSetting.defaults.all.keys.each do |k|
next if !k.to_s.start_with?("saml_")
if val = GlobalSetting.try(k)
puts "Setting #{k} to #{val} in the database"
SiteSetting.add_override!(k, val)
end
end;
SiteSetting.saml_enabled = true
```
Then remove the environment variables and restart the server. The plugin will now be using site settings which can be modified in the admin UI.
### License
MIT
# frozen_string_literal: true
module Jobs
class MigrateSamlUserInfos < ::Jobs::Onceoff
def execute_onceoff(args)
rows = PluginStoreRow.where(plugin_name: "saml").where("key ~* :pat", pat: '^saml_user_')
rows.each do |row|
begin
Oauth2UserInfo.create(
uid: row.key.gsub('saml_user_', ''),
provider: "saml",
user_id: eval(row.value)[:user_id]
)
rescue ActiveRecord::RecordNotUnique => e
# record already migrated
end
end
end
end
end
.btn-social.saml {
background: $quaternary;
.d-icon {
display: none;
}
}
......@@ -3,3 +3,9 @@ en:
login:
saml:
name: "SAML"
title: with SAML
admin_js:
admin:
site_settings:
categories:
saml: SAML
en:
login:
use_saml_auth: "Please use the company SSO to login with your account."
site_settings:
saml_enabled: Enable SAML authentication
saml_target_url: Target URL of the SAML Identity Provider (required)
saml_slo_target_url: Target URL for SAML Single Log Out
saml_name_identifier_format: If provided, will request a specific NameID (UID) format from the identity provider.
saml_cert: X.509 public certificate of the SAML identity provider (either this, or saml_cert_fingerprint, are required)
saml_cert_fingerprint: The X.509 public certificate fingerprint of the SAML identity provider (either this, or `saml_cert`, are required)
saml_cert_fingerprint_algorithm: Which algorithm should be used for SAML certificate fingerprinting?
saml_cert_multi: A secondary X.509 public certificate of the SAML identity provider. Useful during certificate rotations
saml_request_method: The HTTP method used when directing the user to the Identity Provider
saml_sp_certificate: SAML Service Provider X.509 certificate. Used to sign messages once enabled via the `saml_*_signed` settings"
saml_sp_private_key: SAML Service Provider X.509 private key. Used to sign messages once enabled via the `saml_*_signed` settings"
saml_authn_requests_signed: Enable Service Provider signatures for AuthNRequest
saml_want_assertions_signed: Enable Service Provider signatures for Assertions
saml_logout_requests_signed: Enable Service Provider signatures for SP-initiated logout
saml_logout_responses_signed: Enable Service Provider signatures for IDP-initiated logout responses
saml_request_attributes: A list of additional attributes which should be fetched from the service provider. `email`, `name`, `first_name`, and `last_name` are always fetched.
saml_attribute_statements: Custom mappings of fields to their source SAML attributes. In the format `field:samlAttr1,samlAttr2`.
saml_use_attributes_uid: Use the 'uid' attribute as the unique user identifier instead of the default `name_id` field.
saml_skip_email_validation: Skip syntax validation of emails from the SAML IDP
saml_validate_email_fields: If any of these values are present in the `memberOf` attribute, then the email should be considered valid/verified
saml_default_emails_valid: "Consider SAML emails to be verified? Warning: this should only be `true` if you trust the IDP to verify email ownership"
saml_clear_username: Ignore the username from the SAML result
saml_omit_username: Prevent the user from changing the SAML username during signup
saml_auto_create_account: Skip the registration popup during signup with a SAML account
saml_sync_groups: Synchronize SAML groups with Discourse
saml_groups_fullsync: Should the assigned groups be completely synced including adding AND removing groups based on the IDP? Defaults to false. If set to true, `saml_sync_groups_list` and SAML attribute `groups_to_add`/`groups_to_remove` are not used.
saml_groups_attribute: The SAML attribute which contains group names
saml_groups_ldap_leafcn: If your IDP transmits `cn=groupname,cn=groups,dc=example,dc=com` you can set this to true to use only `groupname`. This is useful if you want to keep the standard group name length of Discourse (20 characters).
saml_sync_groups_list: If provided, these are the only Discourse groups which will have their membership controlled by SAML. If blank, all groups_to_add/groups_to_remove are used.
saml_user_field_statements: If provided, user fields will be set based on SAML attributes. Each entry should be in the format `saml_attribute_name:discourse_field_id`
saml_sync_email: On every login, override the user's email using the SAML value. Works the same as the `auth_overrides_email` setting, but is specific to SAML logins.
saml_sync_moderator: Sync moderator status from SAML result?
saml_moderator_attribute: The SAML attribute which contains the moderator boolean
saml_sync_admin: Sync admin status from SAML result?
saml_admin_attribute: The SAML attribute which contains the admin boolean
saml_sync_trust_level: Set user trust level from SAML result
saml_trust_level_attribute: The SAML attribute which contains the trust level integer
saml_sync_locale: Set user locale from SAML result
saml_locale_attribute: The SAML attribute which contains the locale name
saml_forced_domains: Users with email addresses on these domains will be forced to use the SAML flow. They will be blocked from other login methods.
saml_log_auth: Store raw data about authentications in the `plugin_store_rows` database table.
saml_debug_auth: Enable debug logging to `/logs`
saml_base_url: Override the base URL for the Service Provider. Defaults to the forum base URL.
saml:
saml_enabled: false
saml_target_url: ""
saml_slo_target_url: ""
saml_name_identifier_format: ""
saml_cert:
default: ""
textarea: true
saml_cert_fingerprint: ""
saml_cert_fingerprint_algorithm:
type: enum
default: SHA1
choices:
- SHA1
- SHA256
- SHA384
- SHA512
saml_cert_multi:
default: ""
textarea: true
saml_request_method:
type: enum
default: GET
choices:
- GET
- POST
saml_sp_certificate:
default: ""
textarea: true
saml_sp_private_key:
default: ""
textarea: true
saml_authn_requests_signed: false
saml_want_assertions_signed: false
saml_logout_requests_signed: false
saml_logout_responses_signed: false
saml_request_attributes:
type: list
default: ""
saml_attribute_statements:
type: list
default: ""
saml_use_attributes_uid: false
saml_skip_email_validation: false
saml_validate_email_fields:
type: list
default: ""
saml_default_emails_valid: true
saml_clear_username: false
saml_omit_username: false
saml_auto_create_account: false
saml_sync_groups: false
saml_groups_fullsync: false
saml_groups_attribute: "memberOf"
saml_groups_ldap_leafcn: false
saml_sync_groups_list:
type: list
default: ""
saml_user_field_statements:
type: list
default: ""
saml_sync_email: false
saml_sync_moderator: false
saml_moderator_attribute: "isModerator"
saml_sync_admin: false
saml_admin_attribute: "isAdmin"
saml_sync_trust_level: false
saml_trust_level_attribute: "trustLevel"
saml_sync_locale: false
saml_locale_attribute: "locale"
saml_forced_domains:
type: list
default: ""
saml_log_auth: false
saml_debug_auth: false
saml_base_url: ""
# frozen_string_literal: true
class MigrateSamlUserInfo < ActiveRecord::Migration[6.1]
def up
execute <<~SQL
INSERT INTO user_associated_accounts (
provider_name,
provider_uid,
user_id,
info,
last_used,
created_at,
updated_at
) SELECT
'saml',
uid,
user_id,
json_build_object('email', email, 'name', name),
updated_at,
created_at,
updated_at
FROM oauth2_user_infos
WHERE provider = 'saml'
ON CONFLICT DO NOTHING
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
# frozen_string_literal: true
class ::DiscourseSaml::SamlOmniauthStrategy < OmniAuth::Strategies::SAML
option :request_method, "GET"
def request_phase
if options[:request_method] == "POST"
with_settings do |settings|
settings.compress_request = false # Compression used by default for Redirect binding, not POST
authn_request = OneLogin::RubySaml::Authrequest.new
params = authn_request.create_params(settings, additional_params_for_authn_request)
destination = settings.idp_sso_service_url
render_auto_submitted_form(destination: destination, params: params)
end
else
super
end
end
def callback_phase
if request.request_method.downcase.to_sym == :post && !request.params["SameSite"] &&
request.params["SAMLResponse"]
env[Rack::RACK_SESSION_OPTIONS][:skip] = true # Do not set any session cookies. They'll override our SameSite ones
# Make browser re-issue the request in a same-site context so we get cookies
# For this particular action, we explicitly **want** cross-site requests to include session cookies
render_auto_submitted_form(
destination: callback_url,
params: {
"SAMLResponse" => request.params["SAMLResponse"],
"SameSite" => "1",
},
)
else
super
end
end
private
def render_auto_submitted_form(destination:, params:)
submit_script_url =
UrlHelper.absolute(
"#{Discourse.base_path}/plugins/discourse-saml/javascripts/submit-form-on-load.js",
GlobalSetting.cdn_url,
)
inputs = params.map { |key, value| <<~HTML }.join("\n")
<input type="hidden" name="#{CGI.escapeHTML(key)}" value="#{CGI.escapeHTML(value)}"/>
HTML
html = <<~HTML
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<body>
<noscript>
<p>
<strong>Note:</strong> Since your browser does not support JavaScript,
you must press the Continue button once to proceed.
</p>
</noscript>
<form action="#{CGI.escapeHTML(destination)}" method="post">
<div>
#{inputs}
</div>
<noscript>
<div>
<input type="submit" value="Continue"/>
</div>
</noscript>
</form>
<script src="#{CGI.escapeHTML(submit_script_url)}"></script>
</body>
</html>
HTML
r = Rack::Response.new(html, 200, { "content-type" => "text/html" })
r.finish
end
end
This diff is collapsed.