diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2eb09ddc..925d8f6a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -30,7 +30,9 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Create .version file - run: echo ${{ github.ref_name }} > .version + env: + REF_NAME: ${{ github.ref_name }} + run: echo "$REF_NAME" > .version - name: Login to Docker Hub uses: docker/login-action@v3 diff --git a/Gemfile.lock b/Gemfile.lock index 8f63e42e..816c8acd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -149,7 +149,7 @@ GEM crack (1.0.1) bigdecimal rexml - crass (1.0.6) + crass (1.0.7) csv (3.3.5) csv-safe (3.3.1) csv (~> 3.0) @@ -293,7 +293,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.25.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -404,8 +404,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.0) - loofah (~> 2.25) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (8.1.0) i18n (>= 0.7, < 2) diff --git a/app/controllers/esign_settings_controller.rb b/app/controllers/esign_settings_controller.rb index 78630f45..abdc402f 100644 --- a/app/controllers/esign_settings_controller.rb +++ b/app/controllers/esign_settings_controller.rb @@ -14,8 +14,7 @@ class EsignSettingsController < ApplicationController prepend_before_action :maybe_redirect_com, only: %i[show] before_action :load_encrypted_config - authorize_resource :encrypted_config, parent: false, only: %i[new create] - authorize_resource :encrypted_config, only: %i[update destroy show] + authorize_resource :encrypted_config, parent: false def show cert_data = @encrypted_config.value || {} diff --git a/app/controllers/mcp/create_template_controller.rb b/app/controllers/mcp/create_template_controller.rb new file mode 100644 index 00000000..b4c06a63 --- /dev/null +++ b/app/controllers/mcp/create_template_controller.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Mcp + class CreateTemplateController < McpBaseController + SCHEMA = { + name: 'create_template', + title: 'Create Template', + description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \ + 'to create an empty template and receive an edit URL where the file can be uploaded via the UI.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Template name (used as the template name and required when url is not provided)' + }, + url: { + type: 'string', + description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \ + 'created and the returned edit_url can be used to upload a file via the UI.' + } + }, + required: %w[name] + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true + } + }.freeze + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def call + account = current_user.account + + @template = Template.new( + account:, + author: current_user, + folder: account.default_template_folder, + source: :mcp, + name: mcp_params['name'].to_s.presence || 'New Template', + fields: [], + schema: [] + ) + + authorize!(:create, @template) + + if mcp_params['url'].present? + tempfile = Tempfile.new + tempfile.binmode + tempfile.write(DownloadUtils.call(mcp_params['url'], validate: true).body) + tempfile.rewind + + filename = File.basename(URI.decode_www_form_component(mcp_params['url'])) + + file = ActionDispatch::Http::UploadedFile.new( + tempfile:, + filename:, + type: Marcel::MimeType.for(tempfile) + ) + + @template.name = mcp_params['name'].presence || File.basename(filename, '.*') + @template.save! + + documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true) + schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } + + if @template.fields.blank? + @template.fields = Templates::ProcessDocument.normalize_attachment_fields(@template, documents) + end + + @template.update!(schema:) + else + @template.save! + end + + WebhookUrls.enqueue_events(@template, 'template.created') + + SearchEntries.enqueue_reindex(@template) + + render_tool_result( + id: @template.id, + name: @template.name, + edit_url: edit_template_url(@template) + ) + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end +end diff --git a/app/controllers/mcp/load_template_controller.rb b/app/controllers/mcp/load_template_controller.rb new file mode 100644 index 00000000..cefd688a --- /dev/null +++ b/app/controllers/mcp/load_template_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Mcp + class LoadTemplateController < McpBaseController + SCHEMA = { + name: 'load_template', + title: 'Load Template', + description: 'Load a template with its fields. Each field includes name, type, and the signing role name.', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'integer', + description: 'Template identifier' + } + }, + required: %w[template_id] + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + } + }.freeze + + def call + @template = Template.accessible_by(current_ability).find(mcp_params['template_id']) + + authorize!(:read, @template) + + submitters_index = @template.submitters.index_by { |s| s['uuid'] } + + roles = @template.submitters.pluck('name') + + fields = @template.fields.filter_map do |field| + next if field['name'].blank? + + { + name: field['name'], + type: field['type'], + role: submitters_index[field['submitter_uuid']]&.dig('name') + } + end + + render_tool_result( + id: @template.id, + name: @template.name, + roles: roles, + fields: fields + ) + end + end +end diff --git a/app/controllers/mcp/mcp_base_controller.rb b/app/controllers/mcp/mcp_base_controller.rb new file mode 100644 index 00000000..996c350c --- /dev/null +++ b/app/controllers/mcp/mcp_base_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Mcp + class McpBaseController < ActionController::API + wrap_parameters false + + before_action :authenticate_user! + before_action :verify_mcp_enabled! + check_authorization + + before_action do + raise CanCan::AccessDenied unless can?(:manage, :mcp) + end + + rescue_from CanCan::AccessDenied do + render_error(-32_603, 'Forbidden', status: :forbidden) + end + + rescue_from ActiveRecord::RecordNotFound do + render_tool_error('Not found') + end + + private + + def default_url_options + Docuseal.default_url_options + end + + def mcp_body + request.request_parameters + end + + def mcp_params + mcp_body.dig('params', 'arguments') || {} + end + + def render_result(result) + render json: { jsonrpc: '2.0', id: mcp_body['id'], result: } + end + + def render_error(code, message, id: nil, status: :ok) + render json: { jsonrpc: '2.0', id:, error: { code:, message: } }, status: + end + + def render_tool_result(data) + render_result(content: [{ type: 'text', text: data.to_json }]) + end + + def render_tool_error(message) + render_result(content: [{ type: 'text', text: message }], isError: true) + end + + def authenticate_user! + render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user + end + + def verify_mcp_enabled! + return if Docuseal.multitenant? + + return if AccountConfig.exists?(account_id: current_user.account_id, + key: AccountConfig::ENABLE_MCP_KEY, + value: true) + + render json: { error: 'MCP is disabled' }, status: :forbidden + end + + def current_user + @current_user ||= user_from_api_key + end + + def user_from_api_key + token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1] + + return if token.blank? + + sha256 = Digest::SHA256.hexdigest(token) + + User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil }) + end + end +end diff --git a/app/controllers/mcp/protocol_controller.rb b/app/controllers/mcp/protocol_controller.rb new file mode 100644 index 00000000..0cf07584 --- /dev/null +++ b/app/controllers/mcp/protocol_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Mcp + class ProtocolController < McpBaseController + skip_authorization_check + + def ok + head :ok + end + + def initialize_request + render_result( + protocolVersion: '2025-11-25', + serverInfo: { + name: 'DocuSeal', + version: Docuseal.version.to_s + }, + capabilities: { + tools: { + listChanged: false + } + } + ) + end + + def initialized_notification + head :accepted + end + + def ping + render_result({}) + end + + def tools_list + render_result(tools: McpController::TOOLS) + end + + def method_not_found + render_error(-32_601, "Method not found: #{mcp_body['method']}", id: mcp_body['id']) + end + + def tool_not_found + render_error(-32_602, "Unknown tool: #{mcp_body.dig('params', 'name')}", id: mcp_body['id']) + end + + def parse_error + render_error(-32_700, 'Parse error', status: :bad_request) + end + end +end diff --git a/app/controllers/mcp/search_documents_controller.rb b/app/controllers/mcp/search_documents_controller.rb new file mode 100644 index 00000000..f58c0e17 --- /dev/null +++ b/app/controllers/mcp/search_documents_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Mcp + class SearchDocumentsController < McpBaseController + SCHEMA = { + name: 'search_documents', + title: 'Search Documents', + description: 'Search signed or pending documents by submitter name, email, phone, or template name', + inputSchema: { + type: 'object', + properties: { + q: { + type: 'string', + description: 'Search by submitter name, email, phone, or template name' + }, + limit: { + type: 'integer', + description: 'The number of results to return (default 10)' + } + }, + required: %w[q] + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + } + }.freeze + + def call + authorize!(:read, Submission) + + submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active, + mcp_params['q'], search_template: true) + + limit = mcp_params.fetch('limit', 10).to_i + limit = 10 if limit <= 0 + limit = [limit, 100].min + submissions = submissions.preload(:submitters, :template) + .order(id: :desc) + .limit(limit) + + data = submissions.map do |submission| + { + id: submission.id, + template_name: submission.template&.name, + status: Submissions::SerializeForApi.build_status(submission, submission.submitters), + submitters: submission.submitters.map do |s| + { email: s.email, name: s.name, phone: s.phone, status: s.status } + end, + documents_url: submission_url(submission.id) + } + end + + render_tool_result(data) + end + end +end diff --git a/app/controllers/mcp/search_templates_controller.rb b/app/controllers/mcp/search_templates_controller.rb new file mode 100644 index 00000000..bc630b35 --- /dev/null +++ b/app/controllers/mcp/search_templates_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Mcp + class SearchTemplatesController < McpBaseController + SCHEMA = { + name: 'search_templates', + title: 'Search Templates', + description: 'Search document templates by name', + inputSchema: { + type: 'object', + properties: { + q: { + type: 'string', + description: 'Search query to filter templates by name' + }, + limit: { + type: 'integer', + description: 'The number of templates to return (default 10)' + } + }, + required: %w[q] + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + } + }.freeze + + def call + authorize!(:read, Template) + + templates = Templates.search(current_user, Template.accessible_by(current_ability).active, mcp_params['q']) + + limit = mcp_params.fetch('limit', 10).to_i + limit = 10 if limit <= 0 + limit = [limit, 100].min + templates = templates.order(id: :desc).limit(limit) + + render_tool_result(templates.map { |t| { id: t.id, name: t.name } }) + end + end +end diff --git a/app/controllers/mcp/send_documents_controller.rb b/app/controllers/mcp/send_documents_controller.rb new file mode 100644 index 00000000..7fadc8a1 --- /dev/null +++ b/app/controllers/mcp/send_documents_controller.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Mcp + class SendDocumentsController < McpBaseController + SCHEMA = { + name: 'send_documents', + title: 'Send Documents', + description: 'Send a document template for signing to specified submitters', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'integer', + description: 'Template identifier' + }, + submitters: { + type: 'array', + description: 'The list of submitters (signers)', + items: { + type: 'object', + properties: { + email: { + type: 'string', + description: 'Submitter email address' + }, + name: { + type: 'string', + description: 'Submitter name' + }, + phone: { + type: 'string', + description: 'Submitter phone number in E.164 format' + }, + role: { + type: 'string', + description: 'Signing role name from the template' + }, + fields: { + type: 'array', + description: 'Prefill field values for this submitter (fields become readonly)', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Field name' + }, + value: { + description: 'Prefilled value for the field' + } + }, + required: %w[name value] + } + } + } + } + } + }, + required: %w[template_id submitters] + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + } + }.freeze + + # rubocop:disable Metrics + def call + @template = Template.accessible_by(current_ability).find(mcp_params['template_id']) + + authorize!(:read, @template) + + return render_tool_error('Template has been archived') if @template.archived_at? + + authorize!(:create, Submission.new(template: @template, account_id: current_user.account_id)) + + return render_tool_error('Template has no fields') if @template.fields.blank? + + submitters = (mcp_params['submitters'] || []).map do |s| + attrs = s.slice('email', 'name', 'role', 'phone').compact_blank + + fields = Array.wrap(s['fields']).filter_map do |f| + next if f['name'].blank? + + { 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true } + end + + attrs['fields'] = fields if fields.present? + + attrs.with_indifferent_access + end + + submissions = Submissions.create_from_submitters( + template: @template, + user: current_user, + source: :mcp, + submitters_order: @template.preferences['submitters_order'].presence || 'random', + submissions_attrs: { submitters: }, + params: { 'send_email' => true, 'submitters' => submitters } + ) + + return render_tool_error('No valid submitters provided') if submissions.blank? + + WebhookUrls.enqueue_events(submissions, 'submission.created') + + Submissions.send_signature_requests(submissions) + + SearchEntries.enqueue_reindex(submissions) + + submission = submissions.first + + render_tool_result(id: submission.id, status: 'pending') + rescue Submissions::CreateFromSubmitters::BaseError => e + render_tool_error(e.message) + end + # rubocop:enable Metrics + end +end diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 8d7ca288..7f0db5e8 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -1,58 +1,44 @@ # frozen_string_literal: true -class McpController < ActionController::API - before_action :authenticate_user! - before_action :verify_mcp_enabled! +class McpController < ActionController::Metal + TOOL_CONTROLLERS = { + 'search_templates' => Mcp::SearchTemplatesController, + 'load_template' => Mcp::LoadTemplateController, + 'create_template' => Mcp::CreateTemplateController, + 'send_documents' => Mcp::SendDocumentsController, + 'search_documents' => Mcp::SearchDocumentsController + }.freeze - before_action do - authorize!(:manage, :mcp) - end + TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze def call - return head :ok if request.raw_post.blank? + return Mcp::ProtocolController.dispatch(:ok, request, response) if request.raw_post.blank? body = JSON.parse(request.raw_post) + body = nil unless body.is_a?(Hash) - result = Mcp::HandleRequest.call(body, current_user, current_ability) + request.request_parameters = body || {} - if result - render json: result - else - head :accepted - end - rescue CanCan::AccessDenied - render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden - rescue JSON::ParserError - render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request - end + action = + case body&.dig('method') + when 'initialize' then :initialize_request + when 'notifications/initialized' then :initialized_notification + when 'ping' then :ping + when 'tools/list' then :tools_list + when 'tools/call' + tool = TOOL_CONTROLLERS[body.dig('params', 'name')] - private + return tool.dispatch(:call, request, response) if tool - def authenticate_user! - render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user - end + :tool_not_found + else + :method_not_found + end - def verify_mcp_enabled! - return if Docuseal.multitenant? - - return if AccountConfig.exists?(account_id: current_user.account_id, - key: AccountConfig::ENABLE_MCP_KEY, - value: true) - - render json: { error: 'MCP is disabled' }, status: :forbidden - end - - def current_user - @current_user ||= user_from_api_key - end - - def user_from_api_key - token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1] - - return if token.blank? - - sha256 = Digest::SHA256.hexdigest(token) + Mcp::ProtocolController.dispatch(action, request, response) + rescue JSON::ParserError + request.request_parameters = {} - User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil }) + Mcp::ProtocolController.dispatch(:parse_error, request, response) end end diff --git a/app/javascript/elements/html_editor.js b/app/javascript/elements/html_editor.js index 8c9a2d2c..29a5f40e 100644 --- a/app/javascript/elements/html_editor.js +++ b/app/javascript/elements/html_editor.js @@ -106,11 +106,29 @@ img.ProseMirror-separator { } `) +const DROP_ATTRS = [ + 'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster', + 'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile' +] + +const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i + +function isSafeAttr (name, value) { + const lowerName = name.toLowerCase() + + if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false + if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false + + return true +} + function collectDomAttrs (dom) { const attrs = {} for (let i = 0; i < dom.attributes.length; i++) { - attrs[dom.attributes[i].name] = dom.attributes[i].value + const { name, value } = dom.attributes[i] + + if (isSafeAttr(name, value)) attrs[name] = value } return { htmlAttrs: attrs } diff --git a/app/javascript/elements/search_input.js b/app/javascript/elements/search_input.js index 585f7122..e07e6a5c 100644 --- a/app/javascript/elements/search_input.js +++ b/app/javascript/elements/search_input.js @@ -1,19 +1,5 @@ export default class extends HTMLElement { connectedCallback () { - this.input.addEventListener('focus', () => { - if (this.title) { - this.title.classList.add('hidden', 'md:block') - this.input.classList.add('w-60') - } - }) - - this.input.addEventListener('blur', (e) => { - if (this.title && !e.target.value) { - this.title.classList.remove('hidden') - this.input.classList.remove('w-60') - } - }) - this.button.addEventListener('click', (event) => { if (!this.input.value && document.activeElement !== this.input) { event.preventDefault() @@ -21,14 +7,20 @@ export default class extends HTMLElement { this.input.focus() } }) + + document.addEventListener('turbo:before-cache', this.onBeforeCache) } - get input () { - return this.querySelector('input') + disconnectedCallback () { + document.removeEventListener('turbo:before-cache', this.onBeforeCache) + } + + onBeforeCache = () => { + this.input.value = this.input.getAttribute('value') || '' } - get title () { - return document.querySelector(this.dataset.title) + get input () { + return this.querySelector('input') } get button () { diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue index b7b2c05a..1e9f1b67 100644 --- a/app/javascript/submission_form/initials_step.vue +++ b/app/javascript/submission_form/initials_step.vue @@ -169,7 +169,7 @@ class="base-input !text-2xl w-full mt-6 text-center" :required="field.required && !isInitialsStarted" :aria-label="field.name || t('initials')" - :placeholder="`${t('type_initial_here')}...`" + :placeholder="`${t('type_initial_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`" type="text" @focus="$emit('focus')" @input="updateWrittenInitials" @@ -293,7 +293,7 @@ export default { if (!this.isDrawInitials) { this.$nextTick(() => { - if (this.$refs.textInput) { + if (this.$refs.textInput && this.field.required === true) { this.initTextInitial() } }) diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index 68f1dc18..df5c7974 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -247,7 +247,7 @@ class="base-input !text-2xl w-full mt-6" :required="field.required && !isSignatureStarted" :aria-label="field.name || t('signature')" - :placeholder="`${t('type_signature_here')}...`" + :placeholder="`${t('type_signature_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`" type="text" @input="updateWrittenSignature" > @@ -535,7 +535,7 @@ export default { this.$nextTick(() => this.drawSignatureSrc()) } else if (this.isTextSignature) { this.$nextTick(() => { - if (this.$refs.textInput) { + if (this.$refs.textInput && this.field.required === true) { this.initTypedSignature() } }) diff --git a/app/javascript/template_builder/dynamic_editor.js b/app/javascript/template_builder/dynamic_editor.js index d83062a4..cbde1587 100644 --- a/app/javascript/template_builder/dynamic_editor.js +++ b/app/javascript/template_builder/dynamic_editor.js @@ -98,11 +98,29 @@ dynamic-variable { overflow-wrap: anywhere; }`) +const DROP_ATTRS = [ + 'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster', + 'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile' +] + +const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i + +function isSafeAttr (name, value) { + const lowerName = name.toLowerCase() + + if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false + if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false + + return true +} + function collectDomAttrs (dom) { const attrs = {} for (let i = 0; i < dom.attributes.length; i++) { - attrs[dom.attributes[i].name] = dom.attributes[i].value + const { name, value } = dom.attributes[i] + + if (isSafeAttr(name, value)) attrs[name] = value } return { htmlAttrs: attrs } diff --git a/app/javascript/template_builder/dynamic_section.vue b/app/javascript/template_builder/dynamic_section.vue index 9fd81246..852a7a56 100644 --- a/app/javascript/template_builder/dynamic_section.vue +++ b/app/javascript/template_builder/dynamic_section.vue @@ -646,9 +646,7 @@ export default { return } - const container = document.createElement('div') - - container.innerHTML = clipboardHtml + const container = new DOMParser().parseFromString(clipboardHtml, 'text/html').body const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')] diff --git a/app/views/shared/_github.html.erb b/app/views/shared/_github.html.erb index 51083118..a27f7f96 100644 --- a/app/views/shared/_github.html.erb +++ b/app/views/shared/_github.html.erb @@ -1,6 +1,6 @@ <%= svg_icon('start', class: 'h-3 w-3') %> - 17k + 18k diff --git a/app/views/shared/_search_input.html.erb b/app/views/shared/_search_input.html.erb index 1868c658..73f82406 100644 --- a/app/views/shared/_search_input.html.erb +++ b/app/views/shared/_search_input.html.erb @@ -1,4 +1,4 @@ -