diff --git a/rails/app/controllers/concerns/subdomain_common.rb b/rails/app/controllers/concerns/subdomain_common.rb index 76cc71788..aa9016d6d 100644 --- a/rails/app/controllers/concerns/subdomain_common.rb +++ b/rails/app/controllers/concerns/subdomain_common.rb @@ -10,4 +10,8 @@ def manage_iframe_header response.headers.except!('X-Frame-Options') if @site.try(:allow_in_iframe?) end + def etag_header + response.set_header 'ETag', @site.etag + end + end diff --git a/rails/app/controllers/tiddlyspot_controller.rb b/rails/app/controllers/tiddlyspot_controller.rb index 191b42dcb..ecf8a55f6 100644 --- a/rails/app/controllers/tiddlyspot_controller.rb +++ b/rails/app/controllers/tiddlyspot_controller.rb @@ -14,11 +14,13 @@ def home def serve update_access_count_and_timestamp + etag_header render html: @site.html_content.html_safe, layout: false end def download update_access_count_and_timestamp + etag_header download_html_content(@site.html_content, @site.name) end diff --git a/rails/app/controllers/tiddlywiki_controller.rb b/rails/app/controllers/tiddlywiki_controller.rb index 3cd0ad277..450ffb338 100644 --- a/rails/app/controllers/tiddlywiki_controller.rb +++ b/rails/app/controllers/tiddlywiki_controller.rb @@ -7,7 +7,7 @@ class TiddlywikiController < ApplicationController before_action :find_site # TiddlyWiki can't provide the token for saving so we need to skip it - skip_before_action :verify_authenticity_token, only: :save + skip_before_action :verify_authenticity_token, only: [:upload_save, :put_save] # Rails wants a token for options requests, which TiddlyWiki similarly can't provide skip_before_action :verify_authenticity_token, @@ -20,6 +20,11 @@ class TiddlywikiController < ApplicationController def serve return site_not_available unless site_visible? + # Convince TiddlyWiki it can use the put saver + dummy_webdav_header if request.options? && @site.enable_put_saver? + + etag_header + # Avoid site download for head or options requests return head 200 if request.head? || request.options? @@ -35,6 +40,8 @@ def serve def json_content return site_not_available unless site_visible? + etag_header + # Return empty body for options request with CORS headers return head 200 if request.options? @@ -62,6 +69,8 @@ def tid_content # If we get nil, assume the tiddler doesn't exist return head 404 unless tiddler_data + etag_header + # Return empty body for options request with CORS headers return head 200 if request.options? @@ -86,7 +95,8 @@ def download download_html_content(@site.download_content, @site.name) end - def save + # Using the "upload" saver + def upload_save begin if site_saveable? @site.file_upload(params[:userfile]) @@ -102,6 +112,27 @@ def save end end + # Using the "put" saver + def put_save + begin + if site_saveable? + if request.headers['If-Match'].presence && request.headers['If-Match'] != @site.etag + render plain: "The site has been updated since you first loaded it. " + + "Saving now would cause the other changes to be lost.\n", status: 412 + else + @site.file_upload(request.body) + @site.increment_save_count + head 204 + end + else + render plain: "If this is your site please log in at\n#{main_site_url} and try again.\n", status: 403 + end + rescue => e + # Todo: Should probably give a generic "Save failed!" message, and log the real problem + render plain: "#{e.class.name} #{e.message}\n", status: 500 + end + end + private def update_view_count_and_access_timestamp @@ -194,4 +225,9 @@ def cors_headers response.set_header 'Access-Control-Allow-Headers', 'X-Requested-With' end + # TiddlyWiki just checks if the header it exists so the value doesn't matter + def dummy_webdav_header + response.set_header 'dav', "Dummy WebDAV header to enable TiddlyWiki's PUT saver" + end + end diff --git a/rails/app/models/concerns/site_common.rb b/rails/app/models/concerns/site_common.rb index d4683f61c..b823c34e6 100644 --- a/rails/app/models/concerns/site_common.rb +++ b/rails/app/models/concerns/site_common.rb @@ -135,6 +135,11 @@ def site_cache(cache_type, &blk) Rails.cache.fetch(site_content_cache_key, expires_in: 4.weeks.from_now, &blk) end + # Should be a good enough for ETag headers + def etag + Digest::SHA256.base64digest("#{id}#{updated_at}#{blob.key}") + end + def download_url "#{url}/download" end diff --git a/rails/app/models/site.rb b/rails/app/models/site.rb index 3805efd38..3a36467d1 100644 --- a/rails/app/models/site.rb +++ b/rails/app/models/site.rb @@ -56,7 +56,8 @@ def looks_valid? end def html_content(signed_in_user: nil) - th_file.apply_tiddlyhost_mods(name, signed_in_user: signed_in_user).to_html + th_file.apply_tiddlyhost_mods(name, + signed_in_user: signed_in_user, enable_put_saver: enable_put_saver).to_html end def json_data(opts={}) diff --git a/rails/config/routes.rb b/rails/config/routes.rb index 013b0fb0e..fd03b1fe6 100644 --- a/rails/config/routes.rb +++ b/rails/config/routes.rb @@ -29,7 +29,8 @@ get '/favicon.ico', to: 'tiddlywiki#favicon' get '/download', to: 'tiddlywiki#download' - post '/', to: 'tiddlywiki#save' + post '/', to: 'tiddlywiki#upload_save' + put '/', to: 'tiddlywiki#put_save' end # diff --git a/rails/db/migrate/20220404022507_add_put_saver_option_to_sites.rb b/rails/db/migrate/20220404022507_add_put_saver_option_to_sites.rb new file mode 100644 index 000000000..466e992e1 --- /dev/null +++ b/rails/db/migrate/20220404022507_add_put_saver_option_to_sites.rb @@ -0,0 +1,5 @@ +class AddPutSaverOptionToSites < ActiveRecord::Migration[6.1] + def change + add_column :sites, :enable_put_saver, :boolean, default: false + end +end diff --git a/rails/db/schema.rb b/rails/db/schema.rb index ac2d6dcd6..79e1b2957 100644 --- a/rails/db/schema.rb +++ b/rails/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_03_30_234401) do +ActiveRecord::Schema.define(version: 2022_04_04_022507) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -70,6 +70,7 @@ t.integer "raw_byte_size" t.string "tw_version" t.boolean "allow_in_iframe", default: false + t.boolean "enable_put_saver", default: false t.index ["empty_id"], name: "index_sites_on_empty_id" t.index ["name"], name: "index_sites_on_name", unique: true t.index ["user_id"], name: "index_sites_on_user_id" diff --git a/rails/lib/th_file.rb b/rails/lib/th_file.rb index 4f246e4a4..180b63bde 100644 --- a/rails/lib/th_file.rb +++ b/rails/lib/th_file.rb @@ -38,17 +38,22 @@ def self.from_empty(empty_type) from_file(empty_path(empty_type)) end - def apply_tiddlyhost_mods(site_name, for_download: false, signed_in_user: nil) + def apply_tiddlyhost_mods(site_name, for_download: false, enable_put_saver: false, signed_in_user: nil) if is_tw5? - upload_url = if !for_download - # The url for uploads is the same as the site url - Settings.subdomain_site_url(site_name) - else - # Clear $:/UploadURL so the save button in the downloaded file will not try - # to use upload.js. It should use another save method, probably download to file. + upload_url = if for_download || enable_put_saver + # Clear $:/UploadURL for downloads so the save button in the downloaded + # file will not try to use upload.js. It should use another save + # method, probably download to file. + # # Todo: Consider if we should do that also when signed_in_user is nil. + # + # Clear $:/UploadURL when using the put saver otherwise TW will + # prioritize the upload saver "" + else + # The url for uploads is the same as the site url + Settings.subdomain_site_url(site_name) end if !for_download && signed_in_user