From f3a1c564b1982d3af083c83440fa1362012e98e7 Mon Sep 17 00:00:00 2001 From: p4535992 Date: Mon, 3 Aug 2026 08:53:40 +0200 Subject: [PATCH 01/14] feat(dmsf): add ONLYOFFICE Docs view and edit integration Introduce ONLYOFFICE Document Server integration allowing supported DMSF files to be opened for viewing and collaborative editing via the existing server, with callbacks persisting edited content as new DMSF revisions instead of overwriting attachments. - Expose configurable Document Server URLs, JWT settings, and reuse of the official plugin's settings - Add view/edit controls in the file show view and context menu when the file type is supported - Filter force-save callbacks to avoid creating revisions on each automatic save and reject callbacks when newer revisions exist - Document the new plugin settings, migration step, and callback behavior in the README --- README.md | 27 ++ .../dmsf_context_menus_controller.rb | 2 + app/controllers/dmsf_onlyoffice_controller.rb | 244 +++++++++++ app/views/dmsf_context_menus/_file.html.erb | 14 + app/views/dmsf_files/show.html.erb | 8 + app/views/dmsf_onlyoffice/editor.html.erb | 17 + app/views/settings/_dmsf_settings.html.erb | 90 ++++ config/locales/en.yml | 29 ++ config/locales/it.yml | 29 ++ config/routes.rb | 7 + ...d_onlyoffice_key_to_dmsf_file_revisions.rb | 8 + init.rb | 13 + lib/redmine_dmsf.rb | 97 +++++ lib/redmine_dmsf/onlyoffice.rb | 383 ++++++++++++++++++ test/unit/onlyoffice_test.rb | 94 +++++ 15 files changed, 1062 insertions(+) create mode 100644 app/controllers/dmsf_onlyoffice_controller.rb create mode 100644 app/views/dmsf_onlyoffice/editor.html.erb create mode 100644 db/migrate/20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb create mode 100644 lib/redmine_dmsf/onlyoffice.rb create mode 100644 test/unit/onlyoffice_test.rb diff --git a/README.md b/README.md index 6517ad4d..77a9422e 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,33 @@ The command must be runable by the web app's user. Test it in advance, e.g: sudo apt install libreoffice liblibreoffice-java ``` +## ONLYOFFICE Docs integration (optional) + +DMSF can open supported documents in an existing ONLYOFFICE Document Server for viewing and collaborative editing. +Unlike the standard ONLYOFFICE Redmine connector, the callback stores the edited content as a new DMSF revision instead +of overwriting a Redmine `Attachment`. + +Configure the integration in **Administration → Plugins → DMSF → Configure**: + +* Set the public Document Editing Service address. +* Set the internal Document Server and Redmine addresses when the two services communicate through private DNS names. +* Set the same JWT secret, HMAC algorithm, and authorization header used by ONLYOFFICE Docs. +* If the official `onlyoffice_redmine` plugin is installed, DMSF can reuse its server, JWT, internal URL, and TLS settings. +* Leave `office_bin` empty when LibreOffice PDF previews are no longer required. + +After upgrading, run the plugin migration so the callback can record an idempotency key for each saved DMSF revision: + +``` +RAILS_ENV=production bundle exec rake redmine:plugins:migrate NAME=redmine_dmsf +``` + +The Document Server must be able to download signed DMSF URLs and POST callbacks to Redmine. Redmine must be able to +download the saved file URL returned by the Document Server. + +DMSF persists the final ONLYOFFICE callback (status `2`) as a new revision. Force-save callbacks are deliberately not +stored as revisions, which avoids creating a new DMSF version for every automatic save. If another DMSF revision is +uploaded while the editor is open, the callback is rejected rather than replacing newer work. + ## Usage DMSF is designed to act as project module, so it must be checked as an enabled module within the project settings. diff --git a/app/controllers/dmsf_context_menus_controller.rb b/app/controllers/dmsf_context_menus_controller.rb index 0b76886c..0a143308 100644 --- a/app/controllers/dmsf_context_menus_controller.rb +++ b/app/controllers/dmsf_context_menus_controller.rb @@ -39,6 +39,8 @@ def dmsf User.current.allowed_to?(:force_file_unlock, @project)) @email_allowed = User.current.allowed_to?(:email_documents, @project) @preview = RedmineDmsf.office_bin.present? && RedmineDmsf::Preview.office_available? + @onlyoffice_view = RedmineDmsf::OnlyOffice.viewable?(@dmsf_file.name) + @onlyoffice_edit = @onlyoffice_view && RedmineDmsf::OnlyOffice.editable?(@dmsf_file.name) elsif @dmsf_folder @locked = @dmsf_folder.locked? @project = @dmsf_folder.project diff --git a/app/controllers/dmsf_onlyoffice_controller.rb b/app/controllers/dmsf_onlyoffice_controller.rb new file mode 100644 index 00000000..0c71f601 --- /dev/null +++ b/app/controllers/dmsf_onlyoffice_controller.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +class DmsfOnlyofficeController < ApplicationController + menu_item :dmsf + + before_action :require_onlyoffice + before_action :find_file, only: %i[view edit] + before_action :authorize, only: %i[view edit] + before_action :check_dmsf_permissions, only: %i[view edit] + skip_before_action :verify_authenticity_token, only: :callback + + def view + render_editor('view') + end + + def edit + return render_403 if User.current.anonymous? + return render_403 unless RedmineDmsf::OnlyOffice.editable?(@file.name) + return render_403 if @file.locked_for_user? + + render_editor('edit') + end + + def download + claims = RedmineDmsf::OnlyOffice.decode_storage_token(params[:token], purpose: 'download') + file = DmsfFile.visible.find(claims.fetch('file_id')) + revision = DmsfFileRevision.visible.find(claims.fetch('revision_id')) + user = token_user(claims) + raise ActiveRecord::RecordNotFound unless revision.dmsf_file_id == file.id && revision.file.attached? + + previous_user = User.current + User.current = user + unless (user.active? || user.anonymous?) && user.allowed_to?(:view_dmsf_files, file.project) && + DmsfFolder.permissions?(file.dmsf_folder, allow_system: true, file: true) + raise RedmineDmsf::OnlyOffice::InvalidToken, 'The user is no longer allowed to download this DMSF file' + end + + expires_in 0.years, 'must-revalidate' => true + if ActiveStorage::Blob.service.is_a?(ActiveStorage::Service::DiskService) + key = revision.file.blob.key + path = File.join(ActiveStorage::Blob.service.root, key[0..1], key[2..3], key) + send_file path, filename: revision.name, type: revision.content_type, disposition: 'attachment' + else + send_data revision.file.download, filename: revision.name, type: revision.content_type, disposition: 'attachment' + end + rescue RedmineDmsf::OnlyOffice::InvalidToken, ActiveRecord::RecordNotFound => e + Rails.logger.warn "ONLYOFFICE DMSF download rejected: #{e.message}" + render_404 + ensure + User.current = previous_user if defined?(previous_user) + end + + def callback + body = RedmineDmsf::OnlyOffice.callback_payload(request) + claims = RedmineDmsf::OnlyOffice.decode_storage_token(params[:token], purpose: 'callback') + raise RedmineDmsf::OnlyOffice::InvalidToken, 'Document key mismatch' unless body['key'] == claims['key'] + + status = body['status'].to_i + case status + when 2 + save_revision(body, claims) + when 1, 4, 6 + # Status 6 is a force-save. We intentionally persist only the final status 2, + # otherwise repeated force-saves with the same document key would create revision spam. + when 3, 7 + Rails.logger.error "ONLYOFFICE reported save error status #{status} for #{body['key']}" + return render json: { error: 1 } + end + + render json: { error: 0 } + rescue RedmineDmsf::OnlyOffice::Error, ActiveRecord::RecordNotFound => e + Rails.logger.error "ONLYOFFICE DMSF callback failed: #{e.class}: #{e.message}" + render json: { error: 1 } + rescue StandardError => e + backtrace = e.backtrace&.first(10)&.join("\n") + Rails.logger.error "ONLYOFFICE DMSF callback failed: #{e.class}: #{e.message}\n#{backtrace}" + render json: { error: 1 } + end + + private + + def require_onlyoffice + return if RedmineDmsf::OnlyOffice.enabled? + + render_404 + end + + def find_file + @file = DmsfFile.visible.find(params[:id]) + @project = @file.project + rescue ActiveRecord::RecordNotFound + render_404 + end + + def check_dmsf_permissions + return if DmsfFolder.permissions?(@file.dmsf_folder, allow_system: true, file: true) + + render_403 + end + + def token_user(claims) + return User.anonymous if claims['anonymous'] + + User.find(claims.fetch('user_id')) + end + + def render_editor(mode) + @revision = @file.last_revision + raise ActiveRecord::RecordNotFound unless @revision&.file&.attached? + raise ActiveRecord::RecordNotFound unless RedmineDmsf::OnlyOffice.viewable?(@revision.name) + + key = RedmineDmsf::OnlyOffice.document_key(@file, @revision) + # Keep view-only sessions separate from collaborative edit sessions. Otherwise a viewer + # joining last could replace the callback URL selected by ONLYOFFICE for the edit session. + key = "#{key}-view" if mode == 'view' + download_token = RedmineDmsf::OnlyOffice.storage_token( + file: @file, revision: @revision, user: User.current, purpose: 'download', key: key + ) + callback_token = RedmineDmsf::OnlyOffice.storage_token( + file: @file, revision: @revision, user: User.current, purpose: 'callback', key: key, mode: mode + ) + + public_download_url = dmsf_onlyoffice_download_url( + @file, + token: download_token, + filename: @revision.name, + protocol: Setting.protocol, + host: Setting.host_name + ) + public_callback_url = dmsf_onlyoffice_callback_url( + token: callback_token, + protocol: Setting.protocol, + host: Setting.host_name + ) + back_url = dmsf_file_url( + @file, + protocol: Setting.protocol, + host: Setting.host_name + ) + + @onlyoffice_api_url = RedmineDmsf::OnlyOffice.api_url + @onlyoffice_config = RedmineDmsf::OnlyOffice.editor_config( + revision: @revision, + user: User.current, + mode: mode, + key: key, + download_url: RedmineDmsf::OnlyOffice.internal_redmine_url(public_download_url), + callback_url: RedmineDmsf::OnlyOffice.internal_redmine_url(public_callback_url), + back_url: back_url + ) + @mode = mode + response.headers['Cache-Control'] = 'no-store' + render action: 'editor' + end + + def save_revision(body, claims) + raise RedmineDmsf::OnlyOffice::InvalidToken, 'The ONLYOFFICE session is view-only' unless claims['mode'] == 'edit' + + file = DmsfFile.visible.find(claims.fetch('file_id')) + source = DmsfFileRevision.visible.find(claims.fetch('revision_id')) + user = token_user(claims) + raise ActiveRecord::RecordNotFound unless source.dmsf_file_id == file.id + return if DmsfFileRevision.exists?(onlyoffice_key: claims.fetch('key')) + raise RedmineDmsf::OnlyOffice::Error, 'Missing callback file URL' if body['url'].blank? + + previous_user = User.current + User.current = user + unless user.active? && user.allowed_to?(:file_manipulation, file.project) && + DmsfFolder.permissions?(file.dmsf_folder, allow_system: true, file: true) + raise RedmineDmsf::OnlyOffice::Error, 'The editing user is no longer allowed to update this DMSF file' + end + raise RedmineDmsf::OnlyOffice::Error, 'The DMSF file is locked by another user' if file.locked_for_user? + + max_bytes = Setting.attachment_max_size.to_i.kilobytes + max_bytes = nil unless max_bytes.positive? + tempfile = RedmineDmsf::OnlyOffice.download_to_tempfile(body['url'], max_bytes: max_bytes) + revision_created = false + + DmsfFile.transaction do + file = DmsfFile.visible.lock.find(file.id) + next if DmsfFileRevision.exists?(onlyoffice_key: claims.fetch('key')) + + current_revision = file.dmsf_file_revisions.visible.first + unless current_revision&.id == source.id + raise RedmineDmsf::OnlyOffice::Error, + 'A newer DMSF revision was created while the ONLYOFFICE editor was open' + end + raise RedmineDmsf::OnlyOffice::Error, 'The DMSF file is locked by another user' if file.locked_for_user? + + revision = source.clone + revision.user = user + revision.source_revision = source + revision.onlyoffice_key = claims.fetch('key') + revision.comment = I18n.with_locale(user.language.presence || I18n.default_locale) do + I18n.t(:comment_dmsf_onlyoffice_revision) + end + revision.reset_workflow + version_component = RedmineDmsf.onlyoffice_version_constant + if version_component == DmsfFileRevision::PATCH_VERSION && revision.minor_version.nil? + revision.minor_version = 0 + revision.increase_version(version_component) + elsif version_component == DmsfFileRevision::MINOR_VERSION && revision.minor_version.nil? + revision.minor_version = 1 + else + revision.increase_version(version_component) + end + revision.size = tempfile.size + revision.shared_file.attach( + io: tempfile, + filename: source.name, + content_type: source.content_type, + identify: false + ) + + custom_values = source.custom_field_values.to_h do |value| + [value.custom_field_id.to_s, value.value] + end + revision.copy_custom_field_values(ActionController::Parameters.new(custom_values), source) + revision.save! + + file.last_revision = revision + file.save! + revision_created = true + end + + return unless revision_created + + Rails.logger.info "Created DMSF revision #{file.last_revision.id} from ONLYOFFICE document #{claims['key']}" + call_hook :dmsf_helper_upload_after_commit, { file: file } + + begin + DmsfMailer.deliver_files_updated(file.project, [file]) + rescue StandardError => e + Rails.logger.error "Could not send ONLYOFFICE update notifications: #{e.message}" + end + rescue ActiveRecord::RecordNotUnique + raise unless DmsfFileRevision.exists?(onlyoffice_key: claims['key']) + + Rails.logger.info "Ignoring duplicate ONLYOFFICE callback for #{claims['key']}" + ensure + User.current = previous_user if defined?(previous_user) + tempfile&.close! + end +end diff --git a/app/views/dmsf_context_menus/_file.html.erb b/app/views/dmsf_context_menus/_file.html.erb index f0278cad..22e2cb6f 100644 --- a/app/views/dmsf_context_menus/_file.html.erb +++ b/app/views/dmsf_context_menus/_file.html.erb @@ -73,6 +73,20 @@ email_entries: true, back_url: back_url), method: :post, class: 'icon icon-email', disabled: !email_allowed %> +<% if @onlyoffice_view %> +
  • + <%= context_menu_link sprite_icon('zoom-in', l(:label_dmsf_onlyoffice_view)), + dmsf_onlyoffice_view_path(dmsf_file, back_url: back_url), + class: 'icon icon-zoom-in', disabled: false %> +
  • + <% if @onlyoffice_edit %> +
  • + <%= context_menu_link sprite_icon('edit', l(:label_dmsf_onlyoffice_edit)), + dmsf_onlyoffice_edit_path(dmsf_file, back_url: back_url), + class: 'icon icon-edit', disabled: !allowed || dmsf_file.locked_for_user? %> +
  • + <% end %> +<% end %> <% if RedmineDmsf.dmsf_webdav? %>
  • <% if dmsf_file.last_revision && dmsf_file.last_revision.protocol %> diff --git a/app/views/dmsf_files/show.html.erb b/app/views/dmsf_files/show.html.erb index 880aa638..27969122 100644 --- a/app/views/dmsf_files/show.html.erb +++ b/app/views/dmsf_files/show.html.erb @@ -20,6 +20,14 @@ <% html_title l(:dmsf) %>
    + <% if RedmineDmsf::OnlyOffice.viewable?(@file.name) %> + <%= link_to sprite_icon('zoom-in', l(:label_dmsf_onlyoffice_view)), + dmsf_onlyoffice_view_path(@file), class: 'icon icon-zoom-in' %> + <% if @file_manipulation_allowed && RedmineDmsf::OnlyOffice.editable?(@file.name) && !@file.locked_for_user? %> + <%= link_to sprite_icon('edit', l(:label_dmsf_onlyoffice_edit)), + dmsf_onlyoffice_edit_path(@file), class: 'icon icon-edit' %> + <% end %> + <% end %> <% if @file_manipulation_allowed %> <% if @file.locked_for_user? %> <% if User.current.allowed_to?(:force_file_unlock, @project) %> diff --git a/app/views/dmsf_onlyoffice/editor.html.erb b/app/views/dmsf_onlyoffice/editor.html.erb new file mode 100644 index 00000000..07ada990 --- /dev/null +++ b/app/views/dmsf_onlyoffice/editor.html.erb @@ -0,0 +1,17 @@ +<% html_title "#{l(@mode == 'edit' ? :label_dmsf_onlyoffice_edit : :label_dmsf_onlyoffice_view)} - #{@revision.name}" %> + +
    + <%= link_to sprite_icon('back', l(:button_back)), dmsf_file_path(@file), class: 'icon icon-back' %> +
    + +

    <%= l(@mode == 'edit' ? :label_dmsf_onlyoffice_edit : :label_dmsf_onlyoffice_view) %>: <%= h(@revision.name) %>

    + +
    + +<%= javascript_include_tag @onlyoffice_api_url %> +<% editor_javascript = <<~JAVASCRIPT + const dmsfOnlyOfficeConfig = #{json_escape(@onlyoffice_config.to_json)}; + window.dmsfOnlyOfficeEditor = new DocsAPI.DocEditor('dmsf-onlyoffice-editor', dmsfOnlyOfficeConfig); +JAVASCRIPT +%> +<%= javascript_tag editor_javascript.html_safe, nonce: true %> diff --git a/app/views/settings/_dmsf_settings.html.erb b/app/views/settings/_dmsf_settings.html.erb index 0438606d..438df185 100644 --- a/app/views/settings/_dmsf_settings.html.erb +++ b/app/views/settings/_dmsf_settings.html.erb @@ -63,6 +63,96 @@

    +
    + + <%= l(:label_dmsf_onlyoffice) %> + + +<% if Redmine::Plugin.installed?('onlyoffice_redmine') %> +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_use_official_settings) %> + <%= check_box_tag 'settings[dmsf_onlyoffice_use_official_settings]', '1', + RedmineDmsf.onlyoffice_use_official_settings? %> + <%= l(:note_dmsf_onlyoffice_use_official_settings) %> +

    +<% end %> + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_document_server_url) %> + <%= text_field_tag 'settings[dmsf_onlyoffice_document_server_url]', + RedmineDmsf.onlyoffice_document_server_url, size: 60, + placeholder: 'https://onlyoffice.example.org' %> + <%= l(:note_dmsf_onlyoffice_document_server_url) %> +

    + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_document_server_internal_url) %> + <%= text_field_tag 'settings[dmsf_onlyoffice_document_server_internal_url]', + RedmineDmsf.onlyoffice_document_server_internal_url, size: 60, + placeholder: 'http://onlyoffice-documentserver' %> + <%= l(:note_dmsf_onlyoffice_document_server_internal_url) %> +

    + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_redmine_internal_url) %> + <%= text_field_tag 'settings[dmsf_onlyoffice_redmine_internal_url]', + RedmineDmsf.onlyoffice_redmine_internal_url, size: 60, + placeholder: 'http://redmine:3000' %> + <%= l(:note_dmsf_onlyoffice_redmine_internal_url) %> +

    + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_jwt_secret) %> + <%= password_field_tag 'settings[dmsf_onlyoffice_jwt_secret]', RedmineDmsf.onlyoffice_jwt_secret, + size: 60, autocomplete: 'new-password' %> + <%= l(:note_dmsf_onlyoffice_jwt_secret) %> +

    + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_jwt_algorithm) %> + <%= select_tag 'settings[dmsf_onlyoffice_jwt_algorithm]', + options_for_select(%w[HS256 HS384 HS512], RedmineDmsf.onlyoffice_jwt_algorithm) %> + <%= l(:note_dmsf_onlyoffice_jwt_algorithm) %> +

    + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_jwt_header) %> + <%= text_field_tag 'settings[dmsf_onlyoffice_jwt_header]', RedmineDmsf.onlyoffice_jwt_header, size: 30 %> + <%= l(:note_dmsf_onlyoffice_jwt_header) %> +

    + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_disable_certificate_verification) %> + <%= check_box_tag 'settings[dmsf_onlyoffice_disable_certificate_verification]', '1', + RedmineDmsf.onlyoffice_ssl_verification_disabled? %> + <%= l(:note_dmsf_onlyoffice_disable_certificate_verification) %> +

    + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_editable_extensions) %> + <%= text_field_tag 'settings[dmsf_onlyoffice_editable_extensions]', + RedmineDmsf.onlyoffice_editable_extensions.join(','), size: 90 %> + <%= l(:note_dmsf_onlyoffice_editable_extensions) %> +

    + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_version_type) %> + <%= select_tag 'settings[dmsf_onlyoffice_version_type]', + options_for_select([ + [l(:label_dmsf_version_patch), 'patch'], + [l(:label_dmsf_version_minor), 'minor'], + [l(:label_dmsf_version_major), 'major'] + ], RedmineDmsf.onlyoffice_version_type) %> + <%= l(:note_dmsf_onlyoffice_version_type) %> +

    + +

    + <%= content_tag :label, l(:label_dmsf_onlyoffice_token_ttl) %> + <%= number_field_tag 'settings[dmsf_onlyoffice_token_ttl]', RedmineDmsf.onlyoffice_token_ttl, + min: 300, max: 604800, step: 60 %> + <%= l(:note_dmsf_onlyoffice_token_ttl) %> +

    +

    <%= content_tag :label, l(:label_physical_file_delete) %> <%= check_box_tag 'settings[dmsf_really_delete_files]', '1', RedmineDmsf.physical_file_delete? %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 361ee618..4e257bf6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -442,6 +442,35 @@ en: to prevent previews of office documents, put an empty string here. After a change, you might have to restart the application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE label_dmsf_columns: DMS Columns label_column_id: ID diff --git a/config/locales/it.yml b/config/locales/it.yml index 99d2d9bc..dc6acb8b 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -442,6 +442,35 @@ it: # Italian strings thx 2 Matteo Arceci! to prevent previews of office documents, put an empty string here. After a change, you might have to restart the application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: Visualizza in ONLYOFFICE + label_dmsf_onlyoffice_edit: Modifica in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Usa le impostazioni del plugin ONLYOFFICE per Redmine + note_dmsf_onlyoffice_use_official_settings: Riutilizza Document Server, indirizzi interni, JWT e TLS configurati nel plugin ufficiale onlyoffice_redmine. + label_dmsf_onlyoffice_document_server_url: Indirizzo del servizio di modifica documenti + note_dmsf_onlyoffice_document_server_url: URL pubblico del Document Server ONLYOFFICE esistente. Lascia vuoto per disabilitare l'integrazione DMSF. + label_dmsf_onlyoffice_document_server_internal_url: Indirizzo interno del Document Server + note_dmsf_onlyoffice_document_server_internal_url: Indirizzo opzionale usato da Redmine per raggiungere ONLYOFFICE nella rete privata. + label_dmsf_onlyoffice_redmine_internal_url: Indirizzo interno di Redmine + note_dmsf_onlyoffice_redmine_internal_url: Indirizzo opzionale usato da ONLYOFFICE per scaricare i file DMSF e inviare le callback a Redmine. + label_dmsf_onlyoffice_jwt_secret: Segreto JWT + note_dmsf_onlyoffice_jwt_secret: Deve coincidere con il segreto configurato in ONLYOFFICE Docs. Lascia vuoto solo se JWT è disabilitato sul Document Server. + label_dmsf_onlyoffice_jwt_algorithm: Algoritmo JWT + note_dmsf_onlyoffice_jwt_algorithm: Algoritmo HMAC configurato in ONLYOFFICE Docs. HS256 è il valore predefinito standard. + label_dmsf_onlyoffice_jwt_header: Header di autorizzazione JWT + note_dmsf_onlyoffice_jwt_header: Header usato da ONLYOFFICE per il token JWT delle callback. Il valore abituale è Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disabilita la verifica del certificato TLS + note_dmsf_onlyoffice_disable_certificate_verification: Opzione non sicura. Usala solo per test con un certificato autofirmato del Document Server. + label_dmsf_onlyoffice_editable_extensions: Estensioni modificabili + note_dmsf_onlyoffice_editable_extensions: Estensioni separate da virgola apribili in modifica. Gli altri formati supportati restano in sola visualizzazione. + label_dmsf_onlyoffice_version_type: Incremento versione dopo il salvataggio + note_dmsf_onlyoffice_version_type: Una sessione di modifica ONLYOFFICE completata crea una nuova revisione DMSF e incrementa questa parte della versione. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minore + label_dmsf_version_major: Maggiore + label_dmsf_onlyoffice_token_ttl: Durata token editor (secondi) + note_dmsf_onlyoffice_token_ttl: Validità degli URL firmati di download e callback, da 300 secondi a 7 giorni. + comment_dmsf_onlyoffice_revision: Salvato da ONLYOFFICE label_dmsf_columns: Colonne DMS label_column_id: ID diff --git a/config/routes.rb b/config/routes.rb index 2b3715fc..95c817f1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -117,6 +117,13 @@ get '/dmsf/files/:id/revision/obsolete', controller: 'dmsf_files', action: 'obsolete_revision', as: 'obsolete_revision' + get '/dmsf/files/:id/onlyoffice/view', to: 'dmsf_onlyoffice#view', as: 'dmsf_onlyoffice_view' + get '/dmsf/files/:id/onlyoffice/edit', to: 'dmsf_onlyoffice#edit', as: 'dmsf_onlyoffice_edit' + get '/dmsf/files/:id/onlyoffice/download/:filename', to: 'dmsf_onlyoffice#download', + as: 'dmsf_onlyoffice_download', + filename: /[^\/]+/ + post '/dmsf/onlyoffice/callback', to: 'dmsf_onlyoffice#callback', as: 'dmsf_onlyoffice_callback' + get '/dmsf/files/:id/download', to: 'dmsf_files#view', download: '', as: 'download_dmsf_file' # Otherwise will not route nil into the download param diff --git a/db/migrate/20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb b/db/migrate/20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb new file mode 100644 index 00000000..a463bad1 --- /dev/null +++ b/db/migrate/20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddOnlyofficeKeyToDmsfFileRevisions < ActiveRecord::Migration[7.0] + def change + add_column :dmsf_file_revisions, :onlyoffice_key, :string, limit: 128 + add_index :dmsf_file_revisions, :onlyoffice_key, unique: true + end +end diff --git a/init.rb b/init.rb index 826fdbdf..c9d80afc 100644 --- a/init.rb +++ b/init.rb @@ -57,6 +57,17 @@ 'only_approval_zero_minor_version' => '0', 'dmsf_max_notification_receivers_info' => 10, 'office_bin' => 'libreoffice', + 'dmsf_onlyoffice_use_official_settings' => '1', + 'dmsf_onlyoffice_document_server_url' => '', + 'dmsf_onlyoffice_document_server_internal_url' => '', + 'dmsf_onlyoffice_redmine_internal_url' => '', + 'dmsf_onlyoffice_jwt_secret' => '', + 'dmsf_onlyoffice_jwt_algorithm' => 'HS256', + 'dmsf_onlyoffice_jwt_header' => 'Authorization', + 'dmsf_onlyoffice_disable_certificate_verification' => '0', + 'dmsf_onlyoffice_editable_extensions' => RedmineDmsf::OnlyOffice::DEFAULT_EDITABLE_EXTENSIONS.join(','), + 'dmsf_onlyoffice_version_type' => 'minor', + 'dmsf_onlyoffice_token_ttl' => 86_400, 'dmsf_global_menu_disabled' => '0', 'dmsf_default_query' => '0', 'empty_minor_version_by_default' => '0', @@ -112,6 +123,7 @@ dmsf: %i[entries_operation entries_email download_email_entries add_email append_email autocomplete_for_user], dmsf_files: %i[show view thumbnail], + dmsf_onlyoffice: [:view], dmsf_workflows: [:log] }, read: true) @@ -128,6 +140,7 @@ { dmsf_files: %i[create_revision lock unlock delete_revision obsolete_revision notify_activate notify_deactivate restore], + dmsf_onlyoffice: [:edit], dmsf_upload: %i[upload_files upload commit_files commit delete_dmsf_attachment delete_dmsf_link_attachment multi_upload], dmsf_links: %i[new create destroy restore autocomplete_for_project autocomplete_for_folder], diff --git a/lib/redmine_dmsf.rb b/lib/redmine_dmsf.rb index 86597647..6eb6286e 100644 --- a/lib/redmine_dmsf.rb +++ b/lib/redmine_dmsf.rb @@ -188,6 +188,89 @@ def office_bin end end + def onlyoffice_official_settings + return {} unless Redmine::Plugin.installed?('onlyoffice_redmine') + + Setting.plugin_onlyoffice_redmine || {} + rescue NoMethodError + {} + end + + def onlyoffice_use_official_settings? + value = Setting.plugin_redmine_dmsf['dmsf_onlyoffice_use_official_settings'] + enabled = value.to_i.positive? || value == 'true' + enabled && onlyoffice_official_settings.present? + end + + def onlyoffice_document_server_url + onlyoffice_setting('dmsf_onlyoffice_document_server_url', 'oo_address') + end + + def onlyoffice_document_server_internal_url + onlyoffice_setting('dmsf_onlyoffice_document_server_internal_url', 'inner_editor') + end + + def onlyoffice_redmine_internal_url + onlyoffice_setting('dmsf_onlyoffice_redmine_internal_url', 'inner_server') + end + + def onlyoffice_jwt_secret + onlyoffice_setting('dmsf_onlyoffice_jwt_secret', 'jwtsecret') + end + + def onlyoffice_jwt_algorithm + value = onlyoffice_setting('dmsf_onlyoffice_jwt_algorithm', 'jwt_algorithm', 'HS256').upcase + %w[HS256 HS384 HS512].include?(value) ? value : 'HS256' + end + + def onlyoffice_jwt_header + onlyoffice_setting('dmsf_onlyoffice_jwt_header', 'jwtheader', 'Authorization').presence || 'Authorization' + end + + def onlyoffice_ssl_verification_disabled? + if onlyoffice_use_official_settings? + value = onlyoffice_official_settings['check_cert'] + value == 'on' || value == true || value.to_s == '1' + else + value = Setting.plugin_redmine_dmsf['dmsf_onlyoffice_disable_certificate_verification'] + value.to_i.positive? || value == 'true' + end + end + + def onlyoffice_editable_extensions + if onlyoffice_use_official_settings? + extensions = Array(onlyoffice_official_settings['formats_editable']) + .map { |extension| extension.to_s.delete_prefix('.').downcase } + .reject(&:blank?) + return extensions.presence || RedmineDmsf::OnlyOffice::DEFAULT_EDITABLE_EXTENSIONS + end + + value = Setting.plugin_redmine_dmsf['dmsf_onlyoffice_editable_extensions'] + extensions = value.to_s.split(/[\s,;]+/) + .map { |extension| extension.delete_prefix('.').downcase } + .reject(&:blank?) + extensions.presence || RedmineDmsf::OnlyOffice::DEFAULT_EDITABLE_EXTENSIONS + end + + def onlyoffice_version_type + value = Setting.plugin_redmine_dmsf['dmsf_onlyoffice_version_type'].to_s + %w[patch minor major].include?(value) ? value : 'minor' + end + + def onlyoffice_version_constant + case onlyoffice_version_type + when 'patch' then DmsfFileRevision::PATCH_VERSION + when 'major' then DmsfFileRevision::MAJOR_VERSION + else DmsfFileRevision::MINOR_VERSION + end + end + + def onlyoffice_token_ttl + value = Setting.plugin_redmine_dmsf['dmsf_onlyoffice_token_ttl'].to_i + value = 86_400 if value <= 0 + value.clamp(300, 604_800) + end + def dmsf_global_menu_disabled? value = Setting.plugin_redmine_dmsf['dmsf_global_menu_disabled'] value.to_i.positive? || value == 'true' @@ -232,11 +315,25 @@ def dmsf_max_xapian_filesize 3 end end + + private + + def onlyoffice_setting(local_key, official_key, default = '') + value = if onlyoffice_use_official_settings? + onlyoffice_official_settings[official_key] + else + Setting.plugin_redmine_dmsf[local_key] + end + value = default if value.blank? + value.to_s.strip + end end end # DMSF libraries +require "#{File.dirname(__FILE__)}/redmine_dmsf/onlyoffice" + # Validators require "#{File.dirname(__FILE__)}/../app/validators/dmsf_file_name_validator" require "#{File.dirname(__FILE__)}/../app/validators/dmsf_max_file_size_validator" diff --git a/lib/redmine_dmsf/onlyoffice.rb b/lib/redmine_dmsf/onlyoffice.rb new file mode 100644 index 00000000..d3e53506 --- /dev/null +++ b/lib/redmine_dmsf/onlyoffice.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require 'base64' +require 'digest' +require 'json' +require 'net/http' +require 'openssl' +require 'tempfile' +require 'uri' + +module RedmineDmsf + # ONLYOFFICE Docs integration for DMSF files and revisions. + module OnlyOffice + WORD_EXTENSIONS = %w[ + doc docm docx docxf dot dotm dotx epub fb2 fodt htm html mht mhtml odt oform ott rtf stw sxw txt wps wpt xml + ].freeze + CELL_EXTENSIONS = %w[ + csv et ett fods ods ots sxc xls xlsb xlsm xlsx xlt xltm xltx + ].freeze + SLIDE_EXTENSIONS = %w[ + dps dpt fodp odp otp pot potm potx pps ppsm ppsx ppt pptm pptx sxi + ].freeze + PDF_EXTENSIONS = %w[djvu oxps pdf pdfa xps].freeze + VIEWABLE_EXTENSIONS = (WORD_EXTENSIONS + CELL_EXTENSIONS + SLIDE_EXTENSIONS + PDF_EXTENSIONS).uniq.freeze + + DEFAULT_EDITABLE_EXTENSIONS = %w[ + docx docm dotx dotm odt ott rtf txt html htm + xlsx xlsm xltx xltm ods ots csv + pptx pptm potx potm ppsx ppsm odp otp + pdf docxf oform + ].freeze + + JWT_DIGESTS = { + 'HS256' => 'SHA256', + 'HS384' => 'SHA384', + 'HS512' => 'SHA512' + }.freeze + + class Error < StandardError; end + class InvalidToken < Error; end + class InvalidDownloadUrl < Error; end + class FileTooLarge < Error; end + + class << self + def enabled? + url = RedmineDmsf.onlyoffice_document_server_url + return false if url.blank? + + uri = URI.parse(url) + %w[http https].include?(uri.scheme) && uri.host.present? + rescue URI::InvalidURIError + false + end + + def extension(filename) + File.extname(filename.to_s).delete_prefix('.').downcase + end + + def viewable?(filename) + enabled? && VIEWABLE_EXTENSIONS.include?(extension(filename)) + end + + def editable?(filename) + viewable?(filename) && RedmineDmsf.onlyoffice_editable_extensions.include?(extension(filename)) + end + + def document_type(filename) + ext = extension(filename) + return 'word' if WORD_EXTENSIONS.include?(ext) + return 'cell' if CELL_EXTENSIONS.include?(ext) + return 'slide' if SLIDE_EXTENSIONS.include?(ext) + return 'pdf' if PDF_EXTENSIONS.include?(ext) + + 'word' + end + + def document_key(file, revision) + digest = revision.checksum.presence || Digest::SHA256.hexdigest( + [revision.id, revision.updated_at.to_i, revision.size].join(':') + ) + "dmsf-#{file.id}-#{revision.id}-#{digest.to_s.gsub(/[^0-9A-Za-z_-]/, '')[0, 24]}" + end + + def api_url + join_url(RedmineDmsf.onlyoffice_document_server_url, 'web-apps/apps/api/documents/api.js') + end + + def editor_config(revision:, user:, mode:, key:, download_url:, callback_url:, back_url:) + editable = mode == 'edit' + config = { + type: 'desktop', + documentType: document_type(revision.name), + document: { + fileType: extension(revision.name), + key: key, + title: revision.name, + url: download_url, + permissions: { + edit: editable, + download: true, + print: true, + review: editable, + comment: editable, + copy: true + } + }, + editorConfig: { + mode: mode, + lang: user.language.presence || I18n.locale.to_s, + callbackUrl: callback_url, + user: { + id: user.id&.to_s || 'anonymous', + name: user.name + }, + customization: { + forcesave: false, + goback: { + url: back_url + } + } + } + } + + secret = RedmineDmsf.onlyoffice_jwt_secret + if secret.present? + config[:token] = jwt_encode(config, secret, algorithm: RedmineDmsf.onlyoffice_jwt_algorithm) + end + config + end + + def storage_token(file:, revision:, user:, purpose:, key: nil, mode: nil) + claims = { + purpose: purpose, + file_id: file.id, + revision_id: revision.id, + user_id: user.id, + anonymous: user.anonymous?, + key: key || document_key(file, revision), + exp: Time.now.to_i + RedmineDmsf.onlyoffice_token_ttl + } + claims[:mode] = mode if mode.present? + jwt_encode(claims, storage_secret, algorithm: 'HS256') + end + + def decode_storage_token(token, purpose:) + claims = jwt_decode(token, storage_secret, algorithm: 'HS256') + raise InvalidToken, 'Invalid token purpose' unless claims['purpose'] == purpose + + claims + end + + # ONLYOFFICE signs POST callbacks in one of two forms: + # * the JSON body has a `token` whose decoded payload is the callback body; + # * the configured authorization header has a token with a `payload` object. + def callback_payload(request) + raw_body = JSON.parse(request.raw_post.presence || '{}') + secret = RedmineDmsf.onlyoffice_jwt_secret + return raw_body if secret.blank? + + algorithm = RedmineDmsf.onlyoffice_jwt_algorithm + body_token = raw_body['token'].to_s + if body_token.present? + return jwt_decode(body_token, secret, algorithm: algorithm).deep_stringify_keys + end + + header_name = RedmineDmsf.onlyoffice_jwt_header.presence || 'Authorization' + header_token = request.headers[header_name].to_s.sub(/\ABearer\s+/i, '') + if header_token.present? + decoded = jwt_decode(header_token, secret, algorithm: algorithm) + payload = decoded['payload'] + raise InvalidToken, 'Missing callback payload in ONLYOFFICE JWT header' unless payload.is_a?(Hash) + + return payload.deep_stringify_keys + end + + raise InvalidToken, 'Missing ONLYOFFICE callback JWT' if body_token.blank? + rescue JSON::ParserError => e + raise InvalidToken, e.message + end + + def jwt_encode(payload, secret, algorithm: 'HS256') + digest = jwt_digest(algorithm) + header = { alg: algorithm, typ: 'JWT' } + segments = [base64url(header.to_json), base64url(payload.to_json)] + signature = OpenSSL::HMAC.digest(digest, secret, segments.join('.')) + (segments << base64url(signature)).join('.') + end + + def jwt_decode(token, secret, algorithm: 'HS256') + segments = token.to_s.split('.') + raise InvalidToken, 'Malformed JWT' unless segments.size == 3 + + header = JSON.parse(base64url_decode(segments[0])) + raise InvalidToken, 'Unexpected JWT algorithm' unless header['alg'] == algorithm + + expected = OpenSSL::HMAC.digest(jwt_digest(algorithm), secret, segments[0, 2].join('.')) + actual = base64url_decode(segments[2]) + unless actual.bytesize == expected.bytesize && ActiveSupport::SecurityUtils.secure_compare(actual, expected) + raise InvalidToken, 'Invalid JWT signature' + end + + payload = JSON.parse(base64url_decode(segments[1])) + now = Time.now.to_i + raise InvalidToken, 'Expired JWT' if payload['exp'] && payload['exp'].to_i < now + raise InvalidToken, 'JWT is not active yet' if payload['nbf'] && payload['nbf'].to_i > now + + payload + rescue JSON::ParserError, ArgumentError => e + raise InvalidToken, e.message + end + + def internal_redmine_url(public_url) + replacement = RedmineDmsf.onlyoffice_redmine_internal_url + return public_url if replacement.blank? + + uri = URI.parse(public_url) + root = Rails.application.config.relative_url_root.presence || '/' + source_base = uri.dup + source_base.path = root + source_base.query = nil + source_base.fragment = nil + replace_base(public_url, source_base.to_s, replacement) + rescue URI::InvalidURIError + public_url + end + + def internal_document_server_url(url) + replace_base( + url, + RedmineDmsf.onlyoffice_document_server_url, + RedmineDmsf.onlyoffice_document_server_internal_url + ) + end + + def download_to_tempfile(url, max_bytes: nil) + target = internal_document_server_url(url) + validate_document_server_url!(target) + tempfile = Tempfile.new(['dmsf-onlyoffice-', '.tmp']) + tempfile.binmode + fetch(target, tempfile, 0, max_bytes) + tempfile.rewind + tempfile + rescue StandardError + tempfile&.close! + raise + end + + def replace_base(url, source_base, replacement_base) + return url if replacement_base.blank? + + original = URI.parse(url) + source = URI.parse(normalize_base_url(source_base)) + replacement = URI.parse(normalize_base_url(replacement_base)) + return url unless same_origin?(original, source) || same_origin?(original, replacement) + + relative_path = original.path.to_s + source_path = normalized_path(source.path) + if same_origin?(original, source) && source_path != '/' && + (relative_path == source_path || relative_path.start_with?("#{source_path}/")) + relative_path = relative_path.delete_prefix(source_path) + end + + replacement_path = normalized_path(replacement.path) + replacement.path = if replacement_path != '/' && + (relative_path == replacement_path || relative_path.start_with?("#{replacement_path}/")) + normalized_path(relative_path) + else + join_paths(replacement_path, relative_path) + end + replacement.query = original.query + replacement.fragment = original.fragment + replacement.to_s + rescue URI::InvalidURIError + url + end + + def normalize_base_url(url) + value = url.to_s.strip + value.end_with?('/') ? value : "#{value}/" + end + + def join_url(base, path) + URI.join(normalize_base_url(base), path).to_s + end + + private + + def base64url(value) + Base64.urlsafe_encode64(value, padding: false) + end + + def base64url_decode(value) + Base64.urlsafe_decode64(value.to_s + ('=' * ((4 - value.to_s.length % 4) % 4))) + end + + def jwt_digest(algorithm) + JWT_DIGESTS.fetch(algorithm) { raise InvalidToken, "Unsupported JWT algorithm: #{algorithm}" } + end + + def storage_secret + @storage_secret ||= OpenSSL::HMAC.hexdigest( + 'SHA256', Rails.application.secret_key_base, 'redmine-dmsf-onlyoffice-storage-token' + ) + end + + def normalized_path(path) + value = path.to_s + value = "/#{value}" unless value.start_with?('/') + value = value.gsub(%r{/+}, '/') + value.length > 1 ? value.delete_suffix('/') : value + end + + def join_paths(base, suffix) + return normalized_path(suffix) if base == '/' + + normalized_path("#{base}/#{suffix.to_s.delete_prefix('/')}") + end + + def same_origin?(left, right) + left.scheme == right.scheme && left.host == right.host && left.port == right.port + end + + def validate_document_server_url!(url) + uri = URI.parse(url) + allowed = [ + RedmineDmsf.onlyoffice_document_server_url, + RedmineDmsf.onlyoffice_document_server_internal_url + ].filter_map do |base| + next if base.blank? + + parsed = URI.parse(base) + [parsed.scheme, parsed.host, parsed.port] + rescue URI::InvalidURIError + nil + end + unless %w[http https].include?(uri.scheme) && allowed.include?([uri.scheme, uri.host, uri.port]) + raise InvalidDownloadUrl, 'The callback download URL is not a configured ONLYOFFICE Document Server URL' + end + end + + def fetch(url, io, redirects, max_bytes) + raise InvalidDownloadUrl, 'Too many redirects' if redirects > 3 + + uri = URI.parse(url) + validate_document_server_url!(url) + request = Net::HTTP::Get.new(uri.request_uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.verify_mode = if RedmineDmsf.onlyoffice_ssl_verification_disabled? + OpenSSL::SSL::VERIFY_NONE + else + OpenSSL::SSL::VERIFY_PEER + end + http.open_timeout = 15 + http.read_timeout = 120 + + http.request(request) do |response| + case response + when Net::HTTPSuccess + content_length = response['content-length'].to_i + if max_bytes&.positive? && content_length.positive? && content_length > max_bytes + raise FileTooLarge, 'The edited document exceeds Redmine attachment_max_size' + end + + bytes = 0 + response.read_body do |chunk| + bytes += chunk.bytesize + if max_bytes&.positive? && bytes > max_bytes + raise FileTooLarge, 'The edited document exceeds Redmine attachment_max_size' + end + io.write(chunk) + end + when Net::HTTPRedirection + location = URI.join(url, response['location']).to_s + fetch(internal_document_server_url(location), io, redirects + 1, max_bytes) + else + raise Error, "ONLYOFFICE download failed: #{response.code} #{response.message}" + end + end + end + end + end +end diff --git a/test/unit/onlyoffice_test.rb b/test/unit/onlyoffice_test.rb new file mode 100644 index 00000000..6838d9b2 --- /dev/null +++ b/test/unit/onlyoffice_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require File.expand_path('../test_helper', __dir__) + +class OnlyOfficeTest < ActiveSupport::TestCase + test 'maps office extensions to ONLYOFFICE document types' do + assert_equal 'word', RedmineDmsf::OnlyOffice.document_type('example.docx') + assert_equal 'cell', RedmineDmsf::OnlyOffice.document_type('example.xlsx') + assert_equal 'slide', RedmineDmsf::OnlyOffice.document_type('example.pptx') + assert_equal 'pdf', RedmineDmsf::OnlyOffice.document_type('example.pdf') + end + + test 'encodes and verifies HS256 JWT tokens' do + token = RedmineDmsf::OnlyOffice.jwt_encode({ value: 'test', exp: 5.minutes.from_now.to_i }, 'secret') + payload = RedmineDmsf::OnlyOffice.jwt_decode(token, 'secret') + + assert_equal 'test', payload['value'] + end + + test 'rejects JWT tokens signed with another secret' do + token = RedmineDmsf::OnlyOffice.jwt_encode({ value: 'test' }, 'secret') + + assert_raises RedmineDmsf::OnlyOffice::InvalidToken do + RedmineDmsf::OnlyOffice.jwt_decode(token, 'another-secret') + end + end + + test 'supports the HMAC algorithms exposed by ONLYOFFICE settings' do + %w[HS256 HS384 HS512].each do |algorithm| + token = RedmineDmsf::OnlyOffice.jwt_encode({ value: algorithm }, 'secret', algorithm: algorithm) + payload = RedmineDmsf::OnlyOffice.jwt_decode(token, 'secret', algorithm: algorithm) + + assert_equal algorithm, payload['value'] + end + end + + test 'uses the supplied session key in the editor configuration' do + revision = Struct.new(:name).new('example.docx') + user = Struct.new(:id, :name, :language).new(7, 'Editor', 'en') + + RedmineDmsf.stub(:onlyoffice_jwt_secret, '') do + config = RedmineDmsf::OnlyOffice.editor_config( + revision: revision, + user: user, + mode: 'view', + key: 'separate-view-key', + download_url: 'https://redmine.test/download', + callback_url: 'https://redmine.test/callback', + back_url: 'https://redmine.test/dmsf/file/1' + ) + + assert_equal 'separate-view-key', config[:document][:key] + assert_equal false, config[:document][:permissions][:edit] + end + end + + test 'decodes a signed callback token from the request body' do + payload = { 'key' => 'document-key', 'status' => 2 } + token = RedmineDmsf::OnlyOffice.jwt_encode(payload, 'secret') + request = Struct.new(:raw_post, :headers).new({ token: token }.to_json, {}) + + RedmineDmsf.stub(:onlyoffice_jwt_secret, 'secret') do + RedmineDmsf.stub(:onlyoffice_jwt_algorithm, 'HS256') do + RedmineDmsf.stub(:onlyoffice_jwt_header, 'Authorization') do + assert_equal payload, RedmineDmsf::OnlyOffice.callback_payload(request) + end + end + end + end + + test 'decodes a signed callback payload from the authorization header' do + payload = { 'key' => 'document-key', 'status' => 4 } + token = RedmineDmsf::OnlyOffice.jwt_encode({ payload: payload }, 'secret') + request = Struct.new(:raw_post, :headers).new(payload.to_json, { 'Authorization' => "Bearer #{token}" }) + + RedmineDmsf.stub(:onlyoffice_jwt_secret, 'secret') do + RedmineDmsf.stub(:onlyoffice_jwt_algorithm, 'HS256') do + RedmineDmsf.stub(:onlyoffice_jwt_header, 'Authorization') do + assert_equal payload, RedmineDmsf::OnlyOffice.callback_payload(request) + end + end + end + end + + test 'rewrites reverse proxy base paths without duplicating them' do + rewritten = RedmineDmsf::OnlyOffice.replace_base( + 'https://office.example.test/onlyoffice/cache/file.docx?token=1', + 'https://office.example.test/onlyoffice', + 'http://documentserver' + ) + + assert_equal 'http://documentserver/cache/file.docx?token=1', rewritten + end +end From f6be3690f05f6d70607d285b0838c59166800084 Mon Sep 17 00:00:00 2001 From: p4535992 Date: Sat, 8 Aug 2026 08:43:57 +0000 Subject: [PATCH 02/14] fix(dmsf): make ONLYOFFICE module Zeitwerk-compatible --- lib/redmine_dmsf.rb | 2 +- lib/redmine_dmsf/{onlyoffice.rb => only_office.rb} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename lib/redmine_dmsf/{onlyoffice.rb => only_office.rb} (100%) diff --git a/lib/redmine_dmsf.rb b/lib/redmine_dmsf.rb index 6eb6286e..e5f4ffe0 100644 --- a/lib/redmine_dmsf.rb +++ b/lib/redmine_dmsf.rb @@ -332,7 +332,7 @@ def onlyoffice_setting(local_key, official_key, default = '') # DMSF libraries -require "#{File.dirname(__FILE__)}/redmine_dmsf/onlyoffice" +require "#{File.dirname(__FILE__)}/redmine_dmsf/only_office" # Validators require "#{File.dirname(__FILE__)}/../app/validators/dmsf_file_name_validator" diff --git a/lib/redmine_dmsf/onlyoffice.rb b/lib/redmine_dmsf/only_office.rb similarity index 100% rename from lib/redmine_dmsf/onlyoffice.rb rename to lib/redmine_dmsf/only_office.rb From a8299bb320c93a54acf8590de6fbfad74ea95981 Mon Sep 17 00:00:00 2001 From: 4535992 Date: Mon, 10 Aug 2026 15:52:17 +0200 Subject: [PATCH 03/14] fix(test): use Mocha stubs for ONLYOFFICE tests --- test/unit/onlyoffice_test.rb | 52 +++++++++++++++++------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/test/unit/onlyoffice_test.rb b/test/unit/onlyoffice_test.rb index 6838d9b2..54759583 100644 --- a/test/unit/onlyoffice_test.rb +++ b/test/unit/onlyoffice_test.rb @@ -38,20 +38,20 @@ class OnlyOfficeTest < ActiveSupport::TestCase revision = Struct.new(:name).new('example.docx') user = Struct.new(:id, :name, :language).new(7, 'Editor', 'en') - RedmineDmsf.stub(:onlyoffice_jwt_secret, '') do - config = RedmineDmsf::OnlyOffice.editor_config( - revision: revision, - user: user, - mode: 'view', - key: 'separate-view-key', - download_url: 'https://redmine.test/download', - callback_url: 'https://redmine.test/callback', - back_url: 'https://redmine.test/dmsf/file/1' - ) - - assert_equal 'separate-view-key', config[:document][:key] - assert_equal false, config[:document][:permissions][:edit] - end + RedmineDmsf.stubs(:onlyoffice_jwt_secret).returns('') + + config = RedmineDmsf::OnlyOffice.editor_config( + revision: revision, + user: user, + mode: 'view', + key: 'separate-view-key', + download_url: 'https://redmine.test/download', + callback_url: 'https://redmine.test/callback', + back_url: 'https://redmine.test/dmsf/file/1' + ) + + assert_equal 'separate-view-key', config[:document][:key] + assert_equal false, config[:document][:permissions][:edit] end test 'decodes a signed callback token from the request body' do @@ -59,13 +59,11 @@ class OnlyOfficeTest < ActiveSupport::TestCase token = RedmineDmsf::OnlyOffice.jwt_encode(payload, 'secret') request = Struct.new(:raw_post, :headers).new({ token: token }.to_json, {}) - RedmineDmsf.stub(:onlyoffice_jwt_secret, 'secret') do - RedmineDmsf.stub(:onlyoffice_jwt_algorithm, 'HS256') do - RedmineDmsf.stub(:onlyoffice_jwt_header, 'Authorization') do - assert_equal payload, RedmineDmsf::OnlyOffice.callback_payload(request) - end - end - end + RedmineDmsf.stubs(:onlyoffice_jwt_secret).returns('secret') + RedmineDmsf.stubs(:onlyoffice_jwt_algorithm).returns('HS256') + RedmineDmsf.stubs(:onlyoffice_jwt_header).returns('Authorization') + + assert_equal payload, RedmineDmsf::OnlyOffice.callback_payload(request) end test 'decodes a signed callback payload from the authorization header' do @@ -73,13 +71,11 @@ class OnlyOfficeTest < ActiveSupport::TestCase token = RedmineDmsf::OnlyOffice.jwt_encode({ payload: payload }, 'secret') request = Struct.new(:raw_post, :headers).new(payload.to_json, { 'Authorization' => "Bearer #{token}" }) - RedmineDmsf.stub(:onlyoffice_jwt_secret, 'secret') do - RedmineDmsf.stub(:onlyoffice_jwt_algorithm, 'HS256') do - RedmineDmsf.stub(:onlyoffice_jwt_header, 'Authorization') do - assert_equal payload, RedmineDmsf::OnlyOffice.callback_payload(request) - end - end - end + RedmineDmsf.stubs(:onlyoffice_jwt_secret).returns('secret') + RedmineDmsf.stubs(:onlyoffice_jwt_algorithm).returns('HS256') + RedmineDmsf.stubs(:onlyoffice_jwt_header).returns('Authorization') + + assert_equal payload, RedmineDmsf::OnlyOffice.callback_payload(request) end test 'rewrites reverse proxy base paths without duplicating them' do From fa31e501386a77ffe5990632087b50086b1b523e Mon Sep 17 00:00:00 2001 From: 4535992 Date: Mon, 10 Aug 2026 15:54:06 +0200 Subject: [PATCH 04/14] ci: add temporary ONLYOFFICE PR test workflow --- .github/workflows/onlyoffice-pr-test.yml | 130 +++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 .github/workflows/onlyoffice-pr-test.yml diff --git a/.github/workflows/onlyoffice-pr-test.yml b/.github/workflows/onlyoffice-pr-test.yml new file mode 100644 index 00000000..da3da30d --- /dev/null +++ b/.github/workflows/onlyoffice-pr-test.yml @@ -0,0 +1,130 @@ +name: "ONLYOFFICE PR test" + +on: + push: + branches: ["feature/onlyoffice-dmsf-integration"] + workflow_dispatch: + +jobs: + onlyoffice_tests: + strategy: + fail-fast: false + matrix: + engine: [mysql, postgresql, sqlite] + include: + - engine: mysql + database_configuration: > + test: + adapter: mysql2 + database: test + username: redmine + password: redmine + encoding: utf8mb4 + collation: utf8mb4_unicode_ci + sql1: CREATE DATABASE IF NOT EXISTS test CHARACTER SET utf8mb4; + sql2: CREATE USER 'redmine'@'localhost' IDENTIFIED BY 'redmine'; + sql3: GRANT ALL PRIVILEGES ON test.* TO 'redmine'@'localhost'; + database_command: mysql -uroot -proot -e + database_service: mysql + - engine: postgresql + database_configuration: > + test: + adapter: postgresql + database: test + username: redmine + password: redmine + host: localhost + sql1: CREATE ROLE redmine LOGIN ENCRYPTED PASSWORD 'redmine' NOINHERIT VALID UNTIL 'infinity'; + sql2: CREATE DATABASE test WITH ENCODING='UTF8' OWNER=redmine; + sql3: ALTER USER redmine CREATEDB;ALTER ROLE redmine WITH SUPERUSER; + database_command: sudo -u postgres psql -c + database_service: postgresql + - engine: sqlite + database_configuration: > + test: + adapter: sqlite3 + database: db/redmine.sqlite3 + runs-on: ubuntu-latest + env: + RAILS_ENV: test + NAME: redmine_dmsf + steps: + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y litmus libreoffice subversion xapian-omega + + - name: Clone Redmine + run: svn export https://svn.redmine.org/redmine/branches/7.0-stable/ /opt/redmine + + - name: Checkout plugin + uses: actions/checkout@v3 + + - name: Link plugin + run: ln -s "$(pwd)" /opt/redmine/plugins/redmine_dmsf + + - name: Install Ruby and plugin gems + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: '3.4' + + - name: Setup database + run: | + echo "${{ matrix.database_configuration }}" > /opt/redmine/config/database.yml + if [[ "${{ matrix.database_service }}" ]]; then + sudo systemctl start "${{ matrix.engine }}" + fi + if [[ "${{ matrix.database_command }}" ]]; then + ${{ matrix.database_command }} "${{ matrix.sql1 }}" + ${{ matrix.database_command }} "${{ matrix.sql2 }}" + ${{ matrix.database_command }} "${{ matrix.sql3 }}" + fi + + - name: Configure WebDAV + run: | + cp /opt/redmine/config/additional_environment.rb.example /opt/redmine/config/additional_environment.rb + echo "config.log_level = :info" >> /opt/redmine/config/additional_environment.rb + echo "require \"#{Rails.root}/plugins/redmine_dmsf/lib/redmine_dmsf/webdav/custom_middleware\"" >> /opt/redmine/config/additional_environment.rb + echo "config.middleware.insert_before ActionDispatch::Cookies, RedmineDmsf::Webdav::CustomMiddleware" >> /opt/redmine/config/additional_environment.rb + + - name: Configure Active Storage + run: | + echo "require 'active_storage/engine'" >> /opt/redmine/config/additional_environment.rb + echo "require Rails.root.join('plugins', 'redmine_dmsf', 'lib', 'redmine_dmsf', 'xapian_analyzer').to_s" >> /opt/redmine/config/additional_environment.rb + echo "config.active_storage.service = :test" >> /opt/redmine/config/additional_environment.rb + echo "config.active_storage.analyzers.append RedmineDmsf::XapianAnalyzer" >> /opt/redmine/config/additional_environment.rb + echo "test:" > /opt/redmine/config/storage.yml + echo " service: Disk" >> /opt/redmine/config/storage.yml + echo " root: <%= Rails.root.join('dmsf_as_test') %>" >> /opt/redmine/config/storage.yml + + - name: Install Redmine + run: | + cd /opt/redmine + bundle config set --local without 'development' + bundle install + bundle exec rake generate_secret_token + bin/rails active_storage:install + bundle exec rake db:migrate + bundle exec rake redmine:plugins:migrate + bundle exec rake redmine:load_default_data + bundle exec rake assets:precompile + env: + REDMINE_LANG: en + + - name: Run ONLYOFFICE unit tests + run: | + cd /opt/redmine + bin/rails test plugins/redmine_dmsf/test/unit/onlyoffice_test.rb + + - name: Run all plugin unit tests + run: | + cd /opt/redmine + bundle exec rake redmine:plugins:test:units + + - name: Archive test.log + if: always() + uses: actions/upload-artifact@v4 + with: + name: "onlyoffice_test_${{ matrix.engine }}.log" + path: /opt/redmine/log/test.log From 32c42d4e88d57df79483292b51ae5fa83190e83b Mon Sep 17 00:00:00 2001 From: 4535992 Date: Mon, 10 Aug 2026 16:11:00 +0200 Subject: [PATCH 05/14] ci: capture ONLYOFFICE test output --- .github/workflows/onlyoffice-pr-test.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/onlyoffice-pr-test.yml b/.github/workflows/onlyoffice-pr-test.yml index da3da30d..83db5eac 100644 --- a/.github/workflows/onlyoffice-pr-test.yml +++ b/.github/workflows/onlyoffice-pr-test.yml @@ -115,16 +115,22 @@ jobs: - name: Run ONLYOFFICE unit tests run: | cd /opt/redmine - bin/rails test plugins/redmine_dmsf/test/unit/onlyoffice_test.rb + set +e + bin/rails test plugins/redmine_dmsf/test/unit/onlyoffice_test.rb 2>&1 | tee /tmp/onlyoffice_test.out + status=${PIPESTATUS[0]} + set -e + exit "$status" - name: Run all plugin unit tests run: | cd /opt/redmine bundle exec rake redmine:plugins:test:units - - name: Archive test.log + - name: Archive test output if: always() uses: actions/upload-artifact@v4 with: name: "onlyoffice_test_${{ matrix.engine }}.log" - path: /opt/redmine/log/test.log + path: | + /opt/redmine/log/test.log + /tmp/onlyoffice_test.out From dcd8f29485e2b5b144e17568a85e7dc6ca62bfae Mon Sep 17 00:00:00 2001 From: 4535992 Date: Mon, 10 Aug 2026 16:18:26 +0200 Subject: [PATCH 06/14] ci: run ONLYOFFICE tests through plugin rake task --- .github/workflows/onlyoffice-pr-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/onlyoffice-pr-test.yml b/.github/workflows/onlyoffice-pr-test.yml index 83db5eac..4b994a15 100644 --- a/.github/workflows/onlyoffice-pr-test.yml +++ b/.github/workflows/onlyoffice-pr-test.yml @@ -116,7 +116,7 @@ jobs: run: | cd /opt/redmine set +e - bin/rails test plugins/redmine_dmsf/test/unit/onlyoffice_test.rb 2>&1 | tee /tmp/onlyoffice_test.out + TESTOPTS='--name=/OnlyOfficeTest#/' bundle exec rake redmine:plugins:test:units 2>&1 | tee /tmp/onlyoffice_test.out status=${PIPESTATUS[0]} set -e exit "$status" From 89e83fddfec6533b416dfd569746cc5d5cc5f17c Mon Sep 17 00:00:00 2001 From: 4535992 Date: Mon, 10 Aug 2026 16:36:47 +0200 Subject: [PATCH 07/14] Remove temporary ONLYOFFICE CI workflow --- .github/workflows/onlyoffice-pr-test.yml | 136 ----------------------- 1 file changed, 136 deletions(-) delete mode 100644 .github/workflows/onlyoffice-pr-test.yml diff --git a/.github/workflows/onlyoffice-pr-test.yml b/.github/workflows/onlyoffice-pr-test.yml deleted file mode 100644 index 4b994a15..00000000 --- a/.github/workflows/onlyoffice-pr-test.yml +++ /dev/null @@ -1,136 +0,0 @@ -name: "ONLYOFFICE PR test" - -on: - push: - branches: ["feature/onlyoffice-dmsf-integration"] - workflow_dispatch: - -jobs: - onlyoffice_tests: - strategy: - fail-fast: false - matrix: - engine: [mysql, postgresql, sqlite] - include: - - engine: mysql - database_configuration: > - test: - adapter: mysql2 - database: test - username: redmine - password: redmine - encoding: utf8mb4 - collation: utf8mb4_unicode_ci - sql1: CREATE DATABASE IF NOT EXISTS test CHARACTER SET utf8mb4; - sql2: CREATE USER 'redmine'@'localhost' IDENTIFIED BY 'redmine'; - sql3: GRANT ALL PRIVILEGES ON test.* TO 'redmine'@'localhost'; - database_command: mysql -uroot -proot -e - database_service: mysql - - engine: postgresql - database_configuration: > - test: - adapter: postgresql - database: test - username: redmine - password: redmine - host: localhost - sql1: CREATE ROLE redmine LOGIN ENCRYPTED PASSWORD 'redmine' NOINHERIT VALID UNTIL 'infinity'; - sql2: CREATE DATABASE test WITH ENCODING='UTF8' OWNER=redmine; - sql3: ALTER USER redmine CREATEDB;ALTER ROLE redmine WITH SUPERUSER; - database_command: sudo -u postgres psql -c - database_service: postgresql - - engine: sqlite - database_configuration: > - test: - adapter: sqlite3 - database: db/redmine.sqlite3 - runs-on: ubuntu-latest - env: - RAILS_ENV: test - NAME: redmine_dmsf - steps: - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y litmus libreoffice subversion xapian-omega - - - name: Clone Redmine - run: svn export https://svn.redmine.org/redmine/branches/7.0-stable/ /opt/redmine - - - name: Checkout plugin - uses: actions/checkout@v3 - - - name: Link plugin - run: ln -s "$(pwd)" /opt/redmine/plugins/redmine_dmsf - - - name: Install Ruby and plugin gems - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - ruby-version: '3.4' - - - name: Setup database - run: | - echo "${{ matrix.database_configuration }}" > /opt/redmine/config/database.yml - if [[ "${{ matrix.database_service }}" ]]; then - sudo systemctl start "${{ matrix.engine }}" - fi - if [[ "${{ matrix.database_command }}" ]]; then - ${{ matrix.database_command }} "${{ matrix.sql1 }}" - ${{ matrix.database_command }} "${{ matrix.sql2 }}" - ${{ matrix.database_command }} "${{ matrix.sql3 }}" - fi - - - name: Configure WebDAV - run: | - cp /opt/redmine/config/additional_environment.rb.example /opt/redmine/config/additional_environment.rb - echo "config.log_level = :info" >> /opt/redmine/config/additional_environment.rb - echo "require \"#{Rails.root}/plugins/redmine_dmsf/lib/redmine_dmsf/webdav/custom_middleware\"" >> /opt/redmine/config/additional_environment.rb - echo "config.middleware.insert_before ActionDispatch::Cookies, RedmineDmsf::Webdav::CustomMiddleware" >> /opt/redmine/config/additional_environment.rb - - - name: Configure Active Storage - run: | - echo "require 'active_storage/engine'" >> /opt/redmine/config/additional_environment.rb - echo "require Rails.root.join('plugins', 'redmine_dmsf', 'lib', 'redmine_dmsf', 'xapian_analyzer').to_s" >> /opt/redmine/config/additional_environment.rb - echo "config.active_storage.service = :test" >> /opt/redmine/config/additional_environment.rb - echo "config.active_storage.analyzers.append RedmineDmsf::XapianAnalyzer" >> /opt/redmine/config/additional_environment.rb - echo "test:" > /opt/redmine/config/storage.yml - echo " service: Disk" >> /opt/redmine/config/storage.yml - echo " root: <%= Rails.root.join('dmsf_as_test') %>" >> /opt/redmine/config/storage.yml - - - name: Install Redmine - run: | - cd /opt/redmine - bundle config set --local without 'development' - bundle install - bundle exec rake generate_secret_token - bin/rails active_storage:install - bundle exec rake db:migrate - bundle exec rake redmine:plugins:migrate - bundle exec rake redmine:load_default_data - bundle exec rake assets:precompile - env: - REDMINE_LANG: en - - - name: Run ONLYOFFICE unit tests - run: | - cd /opt/redmine - set +e - TESTOPTS='--name=/OnlyOfficeTest#/' bundle exec rake redmine:plugins:test:units 2>&1 | tee /tmp/onlyoffice_test.out - status=${PIPESTATUS[0]} - set -e - exit "$status" - - - name: Run all plugin unit tests - run: | - cd /opt/redmine - bundle exec rake redmine:plugins:test:units - - - name: Archive test output - if: always() - uses: actions/upload-artifact@v4 - with: - name: "onlyoffice_test_${{ matrix.engine }}.log" - path: | - /opt/redmine/log/test.log - /tmp/onlyoffice_test.out From 83a7eaa8db679a8d6bd0e663ed347c29df5cddc9 Mon Sep 17 00:00:00 2001 From: 4535992 Date: Sat, 15 Aug 2026 11:08:50 +0200 Subject: [PATCH 08/14] Fix RuboCop documentation for ONLYOFFICE controller --- app/controllers/dmsf_onlyoffice_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/dmsf_onlyoffice_controller.rb b/app/controllers/dmsf_onlyoffice_controller.rb index 0c71f601..ad11da2b 100644 --- a/app/controllers/dmsf_onlyoffice_controller.rb +++ b/app/controllers/dmsf_onlyoffice_controller.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Handles ONLYOFFICE editor, download, and callback requests for DMSF files. class DmsfOnlyofficeController < ApplicationController menu_item :dmsf From b7e7fd78867158448bbf2a16475830fbce451c5f Mon Sep 17 00:00:00 2001 From: 4535992 Date: Sat, 15 Aug 2026 11:08:59 +0200 Subject: [PATCH 09/14] Fix RuboCop documentation for ONLYOFFICE migration --- .../20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/db/migrate/20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb b/db/migrate/20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb index a463bad1..85d6e884 100644 --- a/db/migrate/20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb +++ b/db/migrate/20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Adds the persisted ONLYOFFICE document key used to deduplicate save callbacks. class AddOnlyofficeKeyToDmsfFileRevisions < ActiveRecord::Migration[7.0] def change add_column :dmsf_file_revisions, :onlyoffice_key, :string, limit: 128 From 71de349226f481e6218e49ff1c39511ba28523a3 Mon Sep 17 00:00:00 2001 From: 4535992 Date: Sat, 15 Aug 2026 11:09:10 +0200 Subject: [PATCH 10/14] Fix RuboCop documentation for ONLYOFFICE tests --- test/unit/onlyoffice_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/onlyoffice_test.rb b/test/unit/onlyoffice_test.rb index 54759583..4666df6e 100644 --- a/test/unit/onlyoffice_test.rb +++ b/test/unit/onlyoffice_test.rb @@ -2,6 +2,7 @@ require File.expand_path('../test_helper', __dir__) +# Unit tests for the DMSF ONLYOFFICE integration helpers. class OnlyOfficeTest < ActiveSupport::TestCase test 'maps office extensions to ONLYOFFICE document types' do assert_equal 'word', RedmineDmsf::OnlyOffice.document_type('example.docx') From cd45873a81b53bf0274e80f2a08223a62204f2ca Mon Sep 17 00:00:00 2001 From: 4535992 Date: Sat, 15 Aug 2026 11:09:35 +0200 Subject: [PATCH 11/14] Fix RuboCop regexp style in ONLYOFFICE route --- config/routes.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index 95c817f1..7cf0fd98 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -121,7 +121,7 @@ get '/dmsf/files/:id/onlyoffice/edit', to: 'dmsf_onlyoffice#edit', as: 'dmsf_onlyoffice_edit' get '/dmsf/files/:id/onlyoffice/download/:filename', to: 'dmsf_onlyoffice#download', as: 'dmsf_onlyoffice_download', - filename: /[^\/]+/ + filename: %r{[^/]+} post '/dmsf/onlyoffice/callback', to: 'dmsf_onlyoffice#callback', as: 'dmsf_onlyoffice_callback' get '/dmsf/files/:id/download', to: 'dmsf_files#view', From 4f0da7d20678b46fa8b64e5ff150a055f8c4041f Mon Sep 17 00:00:00 2001 From: 4535992 Date: Sat, 15 Aug 2026 11:10:05 +0200 Subject: [PATCH 12/14] Fix RuboCop multiple comparison in ONLYOFFICE settings --- lib/redmine_dmsf.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/redmine_dmsf.rb b/lib/redmine_dmsf.rb index e5f4ffe0..3c87c47f 100644 --- a/lib/redmine_dmsf.rb +++ b/lib/redmine_dmsf.rb @@ -230,7 +230,7 @@ def onlyoffice_jwt_header def onlyoffice_ssl_verification_disabled? if onlyoffice_use_official_settings? value = onlyoffice_official_settings['check_cert'] - value == 'on' || value == true || value.to_s == '1' + ['on', true, 1, '1'].include?(value) else value = Setting.plugin_redmine_dmsf['dmsf_onlyoffice_disable_certificate_verification'] value.to_i.positive? || value == 'true' From 57ba8e37488233d854aa9817f3870e602e6c7ba0 Mon Sep 17 00:00:00 2001 From: 4535992 Date: Sat, 15 Aug 2026 11:10:29 +0200 Subject: [PATCH 13/14] Apply RuboCop fixes to ONLYOFFICE helper --- lib/redmine_dmsf/only_office.rb | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/redmine_dmsf/only_office.rb b/lib/redmine_dmsf/only_office.rb index d3e53506..90f4f6a4 100644 --- a/lib/redmine_dmsf/only_office.rb +++ b/lib/redmine_dmsf/only_office.rb @@ -122,9 +122,7 @@ def editor_config(revision:, user:, mode:, key:, download_url:, callback_url:, b } secret = RedmineDmsf.onlyoffice_jwt_secret - if secret.present? - config[:token] = jwt_encode(config, secret, algorithm: RedmineDmsf.onlyoffice_jwt_algorithm) - end + config[:token] = jwt_encode(config, secret, algorithm: RedmineDmsf.onlyoffice_jwt_algorithm) if secret.present? config end @@ -159,9 +157,7 @@ def callback_payload(request) algorithm = RedmineDmsf.onlyoffice_jwt_algorithm body_token = raw_body['token'].to_s - if body_token.present? - return jwt_decode(body_token, secret, algorithm: algorithm).deep_stringify_keys - end + return jwt_decode(body_token, secret, algorithm: algorithm).deep_stringify_keys if body_token.present? header_name = RedmineDmsf.onlyoffice_jwt_header.presence || 'Authorization' header_token = request.headers[header_name].to_s.sub(/\ABearer\s+/i, '') @@ -290,7 +286,7 @@ def base64url(value) end def base64url_decode(value) - Base64.urlsafe_decode64(value.to_s + ('=' * ((4 - value.to_s.length % 4) % 4))) + Base64.urlsafe_decode64(value.to_s + ('=' * ((4 - (value.to_s.length % 4)) % 4))) end def jwt_digest(algorithm) @@ -306,7 +302,7 @@ def storage_secret def normalized_path(path) value = path.to_s value = "/#{value}" unless value.start_with?('/') - value = value.gsub(%r{/+}, '/') + value = value.squeeze('/') value.length > 1 ? value.delete_suffix('/') : value end @@ -333,9 +329,9 @@ def validate_document_server_url!(url) rescue URI::InvalidURIError nil end - unless %w[http https].include?(uri.scheme) && allowed.include?([uri.scheme, uri.host, uri.port]) - raise InvalidDownloadUrl, 'The callback download URL is not a configured ONLYOFFICE Document Server URL' - end + return if %w[http https].include?(uri.scheme) && allowed.include?([uri.scheme, uri.host, uri.port]) + + raise InvalidDownloadUrl, 'The callback download URL is not a configured ONLYOFFICE Document Server URL' end def fetch(url, io, redirects, max_bytes) @@ -368,6 +364,7 @@ def fetch(url, io, redirects, max_bytes) if max_bytes&.positive? && bytes > max_bytes raise FileTooLarge, 'The edited document exceeds Redmine attachment_max_size' end + io.write(chunk) end when Net::HTTPRedirection From ff36d968125889a5e5af85fd6c4b2e8149867680 Mon Sep 17 00:00:00 2001 From: p4535992 Date: Wed, 23 Sep 2026 13:14:14 +0200 Subject: [PATCH 14/14] fix: complete ONLYOFFICE locales and harden callback handling - Add missing English strings to all locales, preserving translations - Add localization regression tests and Rails functional tests - Exempt signed Document Server endpoints from browser login checks - Ensure User.current is restored after callback processing - Normalize Ruby line endings and update test workflow triggers Standalone localization tests and Ruby syntax checks passed. Full Redmine unit and functional suites, RuboCop, and multi-version testing remain pending. These changes have NOT been validated in a complete Redmine environment or with a real ONLYOFFICE Document Server. --- .gitattributes | 6 + .github/workflows/rubyonrails.yml | 35 ++- .rubocop.yml | 3 + README.md | 4 +- app/controllers/dmsf_onlyoffice_controller.rb | 10 +- config/locales/cs.yml | 31 +++ config/locales/de.yml | 31 +++ config/locales/es.yml | 31 +++ config/locales/fa.yml | 31 +++ config/locales/fr.yml | 31 +++ config/locales/hu.yml | 31 +++ config/locales/ja.yml | 31 +++ config/locales/ko.yml | 31 +++ config/locales/nl.yml | 31 +++ config/locales/pl.yml | 31 +++ config/locales/pt-BR.yml | 31 +++ config/locales/sl.yml | 31 +++ config/locales/uk.yml | 31 +++ config/locales/zh-TW.yml | 31 +++ config/locales/zh.yml | 31 +++ .../dmsf_onlyoffice_controller_test.rb | 216 ++++++++++++++++++ test/unit/onlyoffice_locales_test.rb | 97 ++++++++ 22 files changed, 819 insertions(+), 17 deletions(-) create mode 100644 .gitattributes create mode 100644 test/functional/dmsf_onlyoffice_controller_test.rb create mode 100644 test/unit/onlyoffice_locales_test.rb diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..5bdac2ed --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Keep Ruby sources identical on Linux, macOS and Windows checkouts. +*.rb text eol=lf +*.rake text eol=lf +Gemfile text eol=lf +PluginGemfile text eol=lf +Rakefile text eol=lf diff --git a/.github/workflows/rubyonrails.yml b/.github/workflows/rubyonrails.yml index 48d09a01..8c64833a 100644 --- a/.github/workflows/rubyonrails.yml +++ b/.github/workflows/rubyonrails.yml @@ -20,14 +20,18 @@ name: "GitHub CI" on: push: - branches: ["master"] + branches: ["master", "onlyoffice", "feature/onlyoffice-dmsf-integration"] pull_request: - branches: ["master"] + branches: ["master", "onlyoffice"] + workflow_dispatch: +permissions: + contents: read jobs: plugin_tests: strategy: fail-fast: false matrix: + redmine: ["7.0.0", "7.0.1"] engine: [mysql, postgresql, sqlite] include: - engine: mysql @@ -83,8 +87,8 @@ jobs: sudo apt-get update sudo apt-get install -y litmus libreoffice subversion xapian-omega - name: Clone Redmine - # Get the latest stable Redmine - run: svn export https://svn.redmine.org/redmine/branches/7.0-stable/ /opt/redmine + # Test exact releases instead of a moving stable branch + run: svn export https://svn.redmine.org/redmine/tags/${{matrix.redmine}}/ /opt/redmine - name: Checkout code uses: actions/checkout@v3 - name: Link the plugin @@ -94,7 +98,6 @@ jobs: - name: Install Ruby and gems uses: ruby/setup-ruby@v1 # The latest major version with: - bundler-cache: true ruby-version: '3.4' - name: Setup database # Create the database @@ -130,6 +133,7 @@ jobs: echo " service: Disk" >> /opt/redmine/config/storage.yml echo " root: <%= Rails.root.join('dmsf_as_test') %>" >> /opt/redmine/config/storage.yml - name: Install Redmine + id: install # Install Redmine run: | cd /opt/redmine @@ -143,13 +147,21 @@ jobs: bundle exec rake assets:precompile env: REDMINE_LANG: en - - name: Standard tests - # Run the tests + - name: Unit tests + if: ${{ !cancelled() && steps.install.outcome == 'success' }} run: | cd /opt/redmine - bundle exec rake redmine:plugins:test:units - bundle exec rake redmine:plugins:test:functionals - bundle exec rake redmine:plugins:test:integration + bundle exec rake redmine:plugins:test:units NAME=redmine_dmsf RAILS_ENV=test + - name: Functional tests + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: | + cd /opt/redmine + bundle exec rake redmine:plugins:test:functionals NAME=redmine_dmsf RAILS_ENV=test + - name: Integration tests + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: | + cd /opt/redmine + bundle exec rake redmine:plugins:test:integration NAME=redmine_dmsf RAILS_ENV=test - name: Helpers tests run: | cd /opt/redmine @@ -158,6 +170,7 @@ jobs: ruby plugins/redmine_dmsf/test/helpers/dmsf_links_helper_test.rb ruby plugins/redmine_dmsf/test/helpers/dmsf_queries_helper_test.rb - name: Rubocop + if: ${{ !cancelled() && steps.install.outcome == 'success' }} # Run the Rubocop tests run: | cd /opt/redmine @@ -191,5 +204,5 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: "test_${{matrix.engine}}.log" + name: "test_${{matrix.redmine}}_${{matrix.engine}}.log" path: /opt/redmine/log/test.log diff --git a/.rubocop.yml b/.rubocop.yml index b651c80f..86dbba5f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -30,6 +30,9 @@ AllCops: - '**/vendor/**/*' # Rules for DMSF +Layout/EndOfLine: + EnforcedStyle: lf + Layout/LineLength: Exclude: - app/models/dmsf_query.rb diff --git a/README.md b/README.md index a5ae9d70..ef942db0 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,11 @@ Further information about the GPL license can be found at * Editing of office documents * [REST API](https://github.com/picman/redmine_dmsf/wiki/REST-API) * DMS Document revision as a custom field type - * Compatible with Redmine 6.1 + * Compatible with Redmine 7.0 ## Dependencies - * Redmine 6.1 or higher + * Redmine 7.0 or higher ### Full-text search (optional) diff --git a/app/controllers/dmsf_onlyoffice_controller.rb b/app/controllers/dmsf_onlyoffice_controller.rb index ad11da2b..2d6e7caf 100644 --- a/app/controllers/dmsf_onlyoffice_controller.rb +++ b/app/controllers/dmsf_onlyoffice_controller.rb @@ -8,6 +8,8 @@ class DmsfOnlyofficeController < ApplicationController before_action :find_file, only: %i[view edit] before_action :authorize, only: %i[view edit] before_action :check_dmsf_permissions, only: %i[view edit] + # Document Server requests are authenticated by signed tokens, not browser sessions. + skip_before_action :check_if_login_required, only: %i[download callback] skip_before_action :verify_authenticity_token, only: :callback def view @@ -23,13 +25,13 @@ def edit end def download + previous_user = User.current claims = RedmineDmsf::OnlyOffice.decode_storage_token(params[:token], purpose: 'download') file = DmsfFile.visible.find(claims.fetch('file_id')) revision = DmsfFileRevision.visible.find(claims.fetch('revision_id')) user = token_user(claims) raise ActiveRecord::RecordNotFound unless revision.dmsf_file_id == file.id && revision.file.attached? - previous_user = User.current User.current = user unless (user.active? || user.anonymous?) && user.allowed_to?(:view_dmsf_files, file.project) && DmsfFolder.permissions?(file.dmsf_folder, allow_system: true, file: true) @@ -48,7 +50,7 @@ def download Rails.logger.warn "ONLYOFFICE DMSF download rejected: #{e.message}" render_404 ensure - User.current = previous_user if defined?(previous_user) + User.current = previous_user end def callback @@ -155,6 +157,7 @@ def render_editor(mode) end def save_revision(body, claims) + previous_user = User.current raise RedmineDmsf::OnlyOffice::InvalidToken, 'The ONLYOFFICE session is view-only' unless claims['mode'] == 'edit' file = DmsfFile.visible.find(claims.fetch('file_id')) @@ -164,7 +167,6 @@ def save_revision(body, claims) return if DmsfFileRevision.exists?(onlyoffice_key: claims.fetch('key')) raise RedmineDmsf::OnlyOffice::Error, 'Missing callback file URL' if body['url'].blank? - previous_user = User.current User.current = user unless user.active? && user.allowed_to?(:file_manipulation, file.project) && DmsfFolder.permissions?(file.dmsf_folder, allow_system: true, file: true) @@ -239,7 +241,7 @@ def save_revision(body, claims) Rails.logger.info "Ignoring duplicate ONLYOFFICE callback for #{claims['key']}" ensure - User.current = previous_user if defined?(previous_user) + User.current = previous_user tempfile&.close! end end diff --git a/config/locales/cs.yml b/config/locales/cs.yml index 5a4a9ee8..8b9a1772 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -445,6 +445,37 @@ cs: restartovat aplikaci. note_dmsf_office_bin_not_available: "Příkaz LibreOfficu pro příkazovou řádku '%{value}' není k dispozici" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS sloupce label_column_id: ID label_column_title: Název diff --git a/config/locales/de.yml b/config/locales/de.yml index d7241bc5..5f7b16bc 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -439,6 +439,37 @@ de: gestartet werden, damit die Änderung wirksam wird." note_dmsf_office_bin_not_available: "LibreOffice's Binärdatei für die Kommandozeile '%{value}' ist nicht verfügbar." + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Spalten label_column_id: ID label_column_title: Titel diff --git a/config/locales/es.yml b/config/locales/es.yml index 2960c0cb..b81434e1 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -443,6 +443,37 @@ es: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: Título diff --git a/config/locales/fa.yml b/config/locales/fa.yml index 1a67f9e5..ddab4056 100644 --- a/config/locales/fa.yml +++ b/config/locales/fa.yml @@ -477,3 +477,34 @@ fa: errors: messages: error_contains_invalid_character: شامل نویسه‌های غیرمجاز است + + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE diff --git a/config/locales/fr.yml b/config/locales/fr.yml index eff21026..1b5f410e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -443,6 +443,37 @@ fr: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: Titre diff --git a/config/locales/hu.yml b/config/locales/hu.yml index aba175e2..cef9dcbd 100644 --- a/config/locales/hu.yml +++ b/config/locales/hu.yml @@ -442,6 +442,37 @@ hu: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: Cím diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 6a4bef3b..7e22e90c 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -444,6 +444,37 @@ ja: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: タイトル diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 1bc6c9fc..ba44859f 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -443,6 +443,37 @@ ko: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: 제목 diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 705d0d3e..481f6042 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -445,6 +445,37 @@ nl: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: Titel diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 544e6b56..bd4a6e0d 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -443,6 +443,37 @@ pl: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: Tytuł diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index a1faa622..7beb37cd 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -443,6 +443,37 @@ pt-BR: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: Título diff --git a/config/locales/sl.yml b/config/locales/sl.yml index 26b8bea4..b7684d77 100644 --- a/config/locales/sl.yml +++ b/config/locales/sl.yml @@ -443,6 +443,37 @@ sl: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: Naslov diff --git a/config/locales/uk.yml b/config/locales/uk.yml index 0f9728cc..98d475c9 100644 --- a/config/locales/uk.yml +++ b/config/locales/uk.yml @@ -444,6 +444,37 @@ uk: note_dmsf_office_bin_not_available: "LibreOffice's Команда не доступна '%{value}'" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: Колонки DMS label_column_id: ІД label_column_title: Назва diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml index 1a2d25f5..4fab61b2 100644 --- a/config/locales/zh-TW.yml +++ b/config/locales/zh-TW.yml @@ -443,6 +443,37 @@ zh-TW: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: 標題 diff --git a/config/locales/zh.yml b/config/locales/zh.yml index bcd87229..ff59efa8 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -443,6 +443,37 @@ zh: application to take it any effect. note_dmsf_office_bin_not_available: "LibreOffice's command line binary '%{value}' not available" + # ONLYOFFICE: English placeholders for translation. + label_dmsf_onlyoffice: ONLYOFFICE Docs + label_dmsf_onlyoffice_view: View in ONLYOFFICE + label_dmsf_onlyoffice_edit: Edit in ONLYOFFICE + label_dmsf_onlyoffice_use_official_settings: Use ONLYOFFICE Redmine plugin settings + note_dmsf_onlyoffice_use_official_settings: Reuse the Document Server, internal addresses, JWT and TLS settings configured by the official onlyoffice_redmine plugin. + label_dmsf_onlyoffice_document_server_url: Document Editing Service address + note_dmsf_onlyoffice_document_server_url: Public URL of your existing ONLYOFFICE Document Server. Leave empty to disable the DMSF integration. + label_dmsf_onlyoffice_document_server_internal_url: Document Server internal address + note_dmsf_onlyoffice_document_server_internal_url: Optional address used by Redmine to reach ONLYOFFICE inside the private network. + label_dmsf_onlyoffice_redmine_internal_url: Redmine internal address + note_dmsf_onlyoffice_redmine_internal_url: Optional address used by ONLYOFFICE to download DMSF files and send callbacks to Redmine. + label_dmsf_onlyoffice_jwt_secret: JWT secret + note_dmsf_onlyoffice_jwt_secret: Must match the secret configured in ONLYOFFICE Docs. Leave empty only when JWT is disabled on the Document Server. + label_dmsf_onlyoffice_jwt_algorithm: JWT algorithm + note_dmsf_onlyoffice_jwt_algorithm: HMAC algorithm configured in ONLYOFFICE Docs. HS256 is the standard default. + label_dmsf_onlyoffice_jwt_header: JWT authorization header + note_dmsf_onlyoffice_jwt_header: Header used by ONLYOFFICE for callback JWT tokens. The usual value is Authorization. + label_dmsf_onlyoffice_disable_certificate_verification: Disable TLS certificate verification + note_dmsf_onlyoffice_disable_certificate_verification: Insecure. Use only for testing with a self-signed Document Server certificate. + label_dmsf_onlyoffice_editable_extensions: Editable extensions + note_dmsf_onlyoffice_editable_extensions: Comma-separated extensions that may be opened in edit mode. All other supported formats are view-only. + label_dmsf_onlyoffice_version_type: Version increment after save + note_dmsf_onlyoffice_version_type: A completed ONLYOFFICE editing session creates a new DMSF revision and increments this version component. + label_dmsf_version_patch: Patch + label_dmsf_version_minor: Minor + label_dmsf_version_major: Major + label_dmsf_onlyoffice_token_ttl: Editor token lifetime (seconds) + note_dmsf_onlyoffice_token_ttl: Validity of signed download and callback URLs, from 300 seconds to 7 days. + comment_dmsf_onlyoffice_revision: Saved from ONLYOFFICE + label_dmsf_columns: DMS Columns label_column_id: ID label_column_title: 标题 diff --git a/test/functional/dmsf_onlyoffice_controller_test.rb b/test/functional/dmsf_onlyoffice_controller_test.rb new file mode 100644 index 00000000..43ccb125 --- /dev/null +++ b/test/functional/dmsf_onlyoffice_controller_test.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require File.expand_path('../test_helper', __dir__) +require 'tempfile' + +# Exercise signed server-to-server requests independently of browser sessions. +class DmsfOnlyofficeControllerTest < RedmineDmsf::Test::TestCase + def setup + super + @revision = @file1.last_revision + @key = RedmineDmsf::OnlyOffice.document_key(@file1, @revision) + @tempfiles = [] + RedmineDmsf.stubs(:onlyoffice_document_server_url).returns('https://office.example.test') + RedmineDmsf.stubs(:onlyoffice_document_server_internal_url).returns('') + RedmineDmsf.stubs(:onlyoffice_redmine_internal_url).returns('') + RedmineDmsf.stubs(:onlyoffice_jwt_secret).returns('onlyoffice-test-secret') + RedmineDmsf.stubs(:onlyoffice_jwt_algorithm).returns('HS256') + RedmineDmsf.stubs(:onlyoffice_jwt_header).returns('Authorization') + RedmineDmsf.stubs(:onlyoffice_token_ttl).returns(3600) + RedmineDmsf.stubs(:onlyoffice_editable_extensions).returns(%w[txt docx]) + RedmineDmsf.stubs(:onlyoffice_version_constant).returns(DmsfFileRevision::MINOR_VERSION) + DmsfMailer.stubs(:deliver_files_updated) + @callback_token = storage_token('callback') + end + + def teardown + @tempfiles.each(&:close!) + User.current = nil + super + end + + def test_disabled_integration_returns_not_found + RedmineDmsf.stubs(:onlyoffice_document_server_url).returns('') + get "/dmsf/files/#{@file1.id}/onlyoffice/view" + assert_response :not_found + end + + def test_authorized_user_can_open_the_viewer + post '/login', params: { username: 'jsmith', password: 'jsmith' } + get "/dmsf/files/#{@file1.id}/onlyoffice/view" + assert_response :success + assert_select '#dmsf-onlyoffice-editor' + assert_includes response.body, "#{@key}-view" + end + + def test_edit_requires_file_manipulation_permission + @role_manager.remove_permission! :file_manipulation + post '/login', params: { username: 'jsmith', password: 'jsmith' } + get "/dmsf/files/#{@file1.id}/onlyoffice/edit" + assert_response :forbidden + end + + def test_edit_rejects_a_file_locked_by_another_user + DmsfFile.any_instance.stubs(:locked_for_user?).returns(true) + post '/login', params: { username: 'jsmith', password: 'jsmith' } + get "/dmsf/files/#{@file1.id}/onlyoffice/edit" + assert_response :forbidden + end + + def test_signed_callback_works_when_login_is_required + with_settings login_required: '1' do + post_callback(status: 4) + assert_callback_error 0 + end + end + + def test_invalid_callback_token_is_rejected_without_a_login_redirect + with_settings login_required: '1' do + post_callback(status: 4, token: 'invalid-token') + assert_callback_error 1 + end + end + + def test_callback_requires_a_matching_document_key + post_callback(status: 4, key: 'another-document') + assert_callback_error 1 + end + + def test_acknowledged_statuses_do_not_create_revisions + [1, 4, 6].each do |status| + assert_no_difference 'DmsfFileRevision.count' do + post_callback(status: status) + end + assert_callback_error 0 + end + end + + def test_document_server_errors_are_reported + [3, 7].each do |status| + post_callback(status: status) + assert_callback_error 1 + end + end + + def test_completed_edit_creates_a_revision_without_overwriting_the_source + original_content = @revision.file.download + original_values = @revision.custom_field_values.to_h { |value| [value.custom_field_id, value.value] } + stub_edited_document + + assert_difference 'DmsfFileRevision.count', 1 do + post_callback(status: 2) + end + assert_callback_error 0 + + saved = DmsfFileRevision.find_by!(onlyoffice_key: @key) + assert_equal @revision.id, saved.source_revision.id + assert_equal @jsmith.id, saved.user_id + assert_equal @revision.title, saved.title + assert_equal @revision.description, saved.description + assert_nil saved.workflow + assert_nil saved.dmsf_workflow_id + assert_equal original_values, saved.custom_field_values.to_h { |value| [value.custom_field_id, value.value] } + assert_equal 'Edited by ONLYOFFICE', saved.file.download + assert_equal original_content, @revision.reload.file.download + end + + def test_duplicate_callback_does_not_create_another_revision + stub_edited_document + post_callback(status: 2) + assert_callback_error 0 + + assert_no_difference 'DmsfFileRevision.count' do + post_callback(status: 2) + end + assert_callback_error 0 + end + + def test_view_only_session_cannot_save + assert_no_difference 'DmsfFileRevision.count' do + post_callback(status: 2, token: storage_token('callback', mode: 'view')) + end + assert_callback_error 1 + end + + def test_callback_rechecks_file_locks + DmsfFile.any_instance.stubs(:locked_for_user?).returns(true) + assert_no_difference 'DmsfFileRevision.count' do + post_callback(status: 2) + end + assert_callback_error 1 + end + + def test_callback_rechecks_the_editing_users_permissions + @role_manager.remove_permission! :file_manipulation + assert_no_difference 'DmsfFileRevision.count' do + post_callback(status: 2) + end + assert_callback_error 1 + end + + def test_callback_rejects_a_stale_source_revision + newer = @revision.clone + newer.user = @jsmith + newer.save! + stub_edited_document + + assert_no_difference 'DmsfFileRevision.count' do + post_callback(status: 2) + end + assert_callback_error 1 + assert_nil DmsfFileRevision.find_by(onlyoffice_key: @key) + end + + def test_signed_download_works_when_login_is_required + with_settings login_required: '1' do + get "/dmsf/files/#{@file1.id}/onlyoffice/download/#{@revision.name}", + params: { token: storage_token('download') } + assert_response :success + end + end + + def test_invalid_download_token_is_rejected_without_a_login_redirect + with_settings login_required: '1' do + get "/dmsf/files/#{@file1.id}/onlyoffice/download/#{@revision.name}", params: { token: 'invalid-token' } + assert_response :not_found + end + end + + def test_early_save_rejection_preserves_the_current_user + User.current = @jsmith + controller = DmsfOnlyofficeController.new + + assert_raises RedmineDmsf::OnlyOffice::InvalidToken do + controller.send(:save_revision, {}, { 'mode' => 'view' }) + end + assert_equal @jsmith, User.current + end + + private + + def storage_token(purpose, mode: 'edit') + RedmineDmsf::OnlyOffice.storage_token( + file: @file1, revision: @revision, user: @jsmith, purpose: purpose, key: @key, mode: mode + ) + end + + def post_callback(status:, token: @callback_token, key: @key) + payload = { key: key, status: status, url: 'https://office.example.test/cache/edited.txt' } + jwt = RedmineDmsf::OnlyOffice.jwt_encode(payload, 'onlyoffice-test-secret') + post "/dmsf/onlyoffice/callback?token=#{token}", + params: { token: jwt }.to_json, headers: { 'CONTENT_TYPE' => 'application/json' } + end + + def assert_callback_error(expected) + assert_response :success + assert_equal expected, JSON.parse(response.body).fetch('error') + end + + def stub_edited_document + tempfile = Tempfile.new(['dmsf-onlyoffice-test-', '.txt']) + @tempfiles << tempfile + tempfile.write('Edited by ONLYOFFICE') + tempfile.rewind + RedmineDmsf::OnlyOffice.stubs(:download_to_tempfile).returns(tempfile) + end +end diff --git a/test/unit/onlyoffice_locales_test.rb b/test/unit/onlyoffice_locales_test.rb new file mode 100644 index 00000000..19b6acce --- /dev/null +++ b/test/unit/onlyoffice_locales_test.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'yaml' + +# Read each locale directly: I18n fallbacks must not hide missing translations. +# This test also runs without Rails: ruby test/unit/onlyoffice_locales_test.rb +class OnlyOfficeLocalesTest < Minitest::Test + LOCALES_PATH = File.expand_path('../../config/locales', __dir__) + REQUIRED_KEYS = %w[ + label_dmsf_onlyoffice + label_dmsf_onlyoffice_view + label_dmsf_onlyoffice_edit + label_dmsf_onlyoffice_use_official_settings + note_dmsf_onlyoffice_use_official_settings + label_dmsf_onlyoffice_document_server_url + note_dmsf_onlyoffice_document_server_url + label_dmsf_onlyoffice_document_server_internal_url + note_dmsf_onlyoffice_document_server_internal_url + label_dmsf_onlyoffice_redmine_internal_url + note_dmsf_onlyoffice_redmine_internal_url + label_dmsf_onlyoffice_jwt_secret + note_dmsf_onlyoffice_jwt_secret + label_dmsf_onlyoffice_jwt_algorithm + note_dmsf_onlyoffice_jwt_algorithm + label_dmsf_onlyoffice_jwt_header + note_dmsf_onlyoffice_jwt_header + label_dmsf_onlyoffice_disable_certificate_verification + note_dmsf_onlyoffice_disable_certificate_verification + label_dmsf_onlyoffice_editable_extensions + note_dmsf_onlyoffice_editable_extensions + label_dmsf_onlyoffice_version_type + note_dmsf_onlyoffice_version_type + label_dmsf_version_patch + label_dmsf_version_minor + label_dmsf_version_major + label_dmsf_onlyoffice_token_ttl + note_dmsf_onlyoffice_token_ttl + comment_dmsf_onlyoffice_revision + ].freeze + + def test_english_contains_all_required_keys + english = load_locale(File.join(LOCALES_PATH, 'en.yml')) + + REQUIRED_KEYS.each { |key| assert english.key?(key), "Missing English source: #{key}" } + end + + def test_all_locales_contain_the_new_keys_without_fallbacks + locale_files.each do |path| + translations = load_locale(path) + missing = required_keys - translations.keys + + assert_empty missing, "#{File.basename(path)} is missing #{missing.join(', ')}" + end + end + + def test_all_values_are_nonempty_strings_with_matching_interpolation + english = load_locale(File.join(LOCALES_PATH, 'en.yml')) + + locale_files.each do |path| + translations = load_locale(path) + required_keys.each do |key| + value = translations.fetch(key) + message = "#{File.basename(path)}: #{key}" + assert_kind_of String, value, message + refute_empty value.strip, message + assert_equal english.fetch(key).scan(/%\{[^}]+\}/).sort, value.scan(/%\{[^}]+\}/).sort, message + end + end + end + + def test_each_new_key_occurs_exactly_once + locale_files.each do |path| + document = Psych.parse_file(path) + locale_mapping = document.root.children.fetch(1) + keys = locale_mapping.children.each_slice(2).map { |key, _value| key.value } + required_keys.each do |key| + assert_equal 1, keys.count(key), "#{File.basename(path)}: duplicate or missing #{key}" + end + end + end + + private + + def locale_files + Dir.glob(File.join(LOCALES_PATH, '*.yml')).sort + end + + def load_locale(path) + YAML.safe_load_file(path).fetch(File.basename(path, '.yml')) + end + + def required_keys + english = load_locale(File.join(LOCALES_PATH, 'en.yml')) + (REQUIRED_KEYS + english.keys.grep(/onlyoffice/)).uniq + end +end