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 aebccbea..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) @@ -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..2d6e7caf --- /dev/null +++ b/app/controllers/dmsf_onlyoffice_controller.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +# Handles ONLYOFFICE editor, download, and callback requests for DMSF files. +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] + # 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 + 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 + 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? + + 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 + 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) + 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')) + 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? + + 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 + 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 4560045f..eab6fa1c 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/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/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/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/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/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/config/routes.rb b/config/routes.rb index 2b3715fc..7cf0fd98 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: %r{[^/]+} + 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..85d6e884 --- /dev/null +++ b/db/migrate/20260802000001_add_onlyoffice_key_to_dmsf_file_revisions.rb @@ -0,0 +1,9 @@ +# 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 + add_index :dmsf_file_revisions, :onlyoffice_key, unique: true + end +end diff --git a/init.rb b/init.rb index c15835da..0437751d 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..3c87c47f 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'] + ['on', true, 1, '1'].include?(value) + 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/only_office" + # 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/only_office.rb b/lib/redmine_dmsf/only_office.rb new file mode 100644 index 00000000..90f4f6a4 --- /dev/null +++ b/lib/redmine_dmsf/only_office.rb @@ -0,0 +1,380 @@ +# 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 + config[:token] = jwt_encode(config, secret, algorithm: RedmineDmsf.onlyoffice_jwt_algorithm) if secret.present? + 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 + 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, '') + 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.squeeze('/') + 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 + 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) + 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/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 diff --git a/test/unit/onlyoffice_test.rb b/test/unit/onlyoffice_test.rb new file mode 100644 index 00000000..4666df6e --- /dev/null +++ b/test/unit/onlyoffice_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +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') + 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.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 + 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.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 + 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.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 + 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