From 4ab13c424401586a6aa040044fbf4fadcd0b9eae Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 19 Oct 2026 23:08:59 +0900 Subject: [PATCH] #428 --- .../app/controllers/application_controller.rb | 26 ++- .../app/controllers/nico_tags_controller.rb | 6 +- backend/app/controllers/posts_controller.rb | 106 ++++++++++++- backend/app/controllers/tags_controller.rb | 2 +- backend/app/controllers/users_controller.rb | 32 +++- .../app/controllers/wiki_pages_controller.rb | 16 +- backend/config/application.rb | 9 ++ .../initializers/filter_parameter_logging.rb | 4 +- backend/config/routes.rb | 6 +- backend/spec/rails_helper.rb | 8 + .../post_locale_filter_identity_spec.rb | 43 +++++ backend/spec/requests/posts_spec.rb | 2 +- .../tag_description_wiki_write_spec.rb | 2 +- .../spec/requests/tag_descriptions_spec.rb | 6 +- .../spec/requests/tag_group_locale_spec.rb | 2 +- .../spec/requests/tag_group_markdown_spec.rb | 2 +- .../requests/tag_group_wiki_write_spec.rb | 8 +- .../tag_group_write_integrity_spec.rb | 4 +- backend/spec/requests/tag_groups_spec.rb | 8 +- .../spec/requests/tag_resource_ids_spec.rb | 2 +- backend/spec/requests/users_spec.rb | 99 +++++++++++- backend/spec/requests/wiki_conflict_spec.rb | 2 +- .../requests/wiki_creation_freeze_spec.rb | 4 +- backend/spec/requests/wiki_locale_spec.rb | 2 +- backend/spec/requests/wiki_spec.rb | 6 +- .../requests/wiki_title_collision_spec.rb | 2 +- .../requests/wiki_unmigrated_contract_spec.rb | 2 +- backend/spec/support/auth_helper.rb | 12 ++ frontend/server.mjs | 97 ++++-------- frontend/src/App.tsx | 97 ++++++------ frontend/src/components/TopNav.tsx | 44 +++++- .../src/components/users/InheritDialogue.tsx | 3 +- .../src/components/users/UserCodeDialogue.tsx | 3 +- frontend/src/config.sample.ts | 4 +- frontend/src/entry-server.tsx | 15 +- frontend/src/lib/api.test.ts | 77 ++++++++- frontend/src/lib/api.ts | 149 ++++++++++++++---- frontend/src/lib/posts.ts | 32 +++- frontend/src/lib/prefetchers.test.ts | 4 +- frontend/src/lib/prefetchers.ts | 17 +- frontend/src/lib/queryKeys.ts | 4 +- frontend/src/lib/renderContext.ts | 4 +- frontend/src/lib/routeLocales.ts | 9 +- frontend/src/lib/users.session.test.ts | 69 ++++++++ frontend/src/lib/users.ts | 64 ++++++++ frontend/src/lib/wiki.test.ts | 58 ++++++- frontend/src/lib/wiki.ts | 72 ++++++++- frontend/src/pages/posts/PostListPage.tsx | 1 + frontend/src/pages/posts/PostSearchPage.tsx | 1 + frontend/src/pages/tags/NicoTagListPage.tsx | 9 +- frontend/src/pages/tags/TagHistoryPage.tsx | 13 +- frontend/src/pages/tags/TagListPage.tsx | 16 +- .../src/pages/wiki/WikiDetailPage.test.tsx | 5 +- frontend/src/pages/wiki/WikiDetailPage.tsx | 34 ++-- frontend/src/types.ts | 3 + frontend/vite.config.ts | 3 +- 56 files changed, 1071 insertions(+), 259 deletions(-) create mode 100644 backend/spec/requests/post_locale_filter_identity_spec.rb create mode 100644 frontend/src/lib/users.session.test.ts diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 9fe5104..4f2610f 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -1,4 +1,7 @@ class ApplicationController < ActionController::API + include ActionController::Cookies + include ActionController::RequestForgeryProtection + class UnsupportedLocale < StandardError ; end @@ -11,18 +14,39 @@ class ApplicationController < ActionController::API before_action :authenticate_user before_action :reject_banned_user! before_action :validate_explicit_locale! + protect_from_forgery with: :exception + self.forgery_protection_origin_check = true + rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf + after_action :prevent_authenticated_caching def current_user = @current_user private + def reject_invalid_csrf + response.headers['X-CSRF-Invalid'] = '1' + response.headers['Cache-Control'] = 'private, no-store' + head :forbidden + end + def authenticate_user - code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE'] + code = session[:inheritance_code] return if code.blank? @current_user = User.find_by(inheritance_code: code) end + def prevent_authenticated_caching + response.headers['Cache-Control'] = 'private, no-store' if current_user + end + + def establish_user_session user + reset_session + session[:inheritance_code] = user.inheritance_code + @current_user = user + response.headers['Cache-Control'] = 'private, no-store' + end + def bool? key, default: false return default if params[key].nil? diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index effb0e9..86a3af9 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -24,7 +24,7 @@ class NicoTagsController < ApplicationController q = ExternalTag - .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ + .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " + 'ON post_tag_max.external_tag_id = external_tags.id') .includes(linked_tags: [:tag_names, :tag_descriptions]) if name @@ -37,7 +37,7 @@ class NicoTagsController < ApplicationController linked_tag_ids = Tag .joins(:tag_names) - .where(tag_names: { language_code: 'ja', primary_flg: true }) + .where(tag_names: { language_code: locale.language_code, primary_flg: true }) .where('tag_names.name LIKE ?', "%#{ linked_tag }%") .pluck(:id) linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id) @@ -45,7 +45,7 @@ class NicoTagsController < ApplicationController end if link_status.in?(['linked', 'unlinked']) exists_sql = - 'EXISTS (SELECT 1 FROM nico_tag_relations ' \ + 'EXISTS (SELECT 1 FROM nico_tag_relations ' + 'WHERE nico_tag_relations.nico_tag_id = external_tags.id)' q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }") end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index d08e466..4593052 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -400,8 +400,76 @@ class PostsController < ApplicationController render_post_form_record_invalid e.record end + def tag_filters + source = resolve_locale! + code = params[:target_locale] + return head :not_found unless code.is_a?(String) && Locale.codes.value?(code) + + target = Locale.find_by(code:, deprecated_at: nil) + return head :not_found unless target + + literals = params[:tags].to_s.split + references = tag_filter_references(literals) + names = literals.each_with_index.map do |literal, index| + reference = references&.[](index) + (reference && reference['negative'] == false) ? literal : literal.sub(/\Anot:/i, '') + end + internal = references ? {} : TagName.where( + language_code: source.language_code, name: names).pluck(:name, :tag_id).to_h + canonical = references ? {} : TagName.where( + language_code: source.language_code, primary_flg: true, + tag_id: internal.values).pluck(:tag_id, :name).to_h + external_names = names.map { canonical[internal[_1]] || _1 } + external = references ? {} : ExternalTag.where( + "CONCAT(platform, ':', name) IN (?)", external_names).pluck( + Arel.sql("CONCAT(platform, ':', name)"), :id).to_h + ids = references ? references.filter_map { _1['tag_id'] } : internal.values + translated = TagName.where(language_code: target.language_code, + primary_flg: true, tag_id: ids).pluck(:tag_id, :name).to_h + + filters = literals.each_with_index.map do |literal, index| + name = names[index] + reference = if references + references[index].except('name') + else + matches = {} + matches['tag_id'] = internal[name] if internal[name] + external_id = external[external_names[index]] + matches['external_tag_id'] = external_id if external_id + matches.empty? ? { 'missing' => true } : matches + end + name = translated[reference['tag_id']] || name + negative = reference.fetch('negative', literal.downcase.start_with?('not:')) + prefix = negative ? literal[/\Anot:/i] || 'not:' : '' + reference.merge('name' => "#{ prefix }#{ name }", 'negative' => negative) + end + render json: { references: filters } + end + private + def tag_filter_references literals + raw = params[:tag_refs] + return nil if raw.blank? + + references = JSON.parse(raw) + valid = references.is_a?(Array) && references.all? do |reference| + next false unless reference.is_a?(Hash) && reference['name'].is_a?(String) + + ids = [reference['tag_id'], reference['external_tag_id']].compact + ((ids.any? || reference['missing'] == true) && + ids.all? { _1.is_a?(Integer) && _1.positive? } && + (!reference.key?('missing') || (reference['missing'] == true && ids.empty?)) && + (!reference.key?('negative') || [true, false].include?(reference['negative']))) + end + raise ActionController::BadRequest unless valid + return nil unless references.map { _1['name'] } == literals + + references + rescue JSON::ParserError, TypeError + raise ActionController::BadRequest + end + def filtered_posts locale tag_names = params[:tags].to_s.split match_type = params[:match] @@ -413,20 +481,29 @@ class PostsController < ApplicationController end def filter_posts_by_tags locale, tag_names, match_type - literals = tag_names.map do |raw_name| - { name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first, - negative: raw_name.downcase.start_with?('not:') } + references = tag_filter_references(tag_names) + literals = tag_names.each_with_index.map do |raw_name, index| + name = raw_name.sub(/\Anot:/i, '') + name = TagName.canonicalise(locale, name).first unless references + reference = references&.[](index) + negative = if reference&.key?('negative') + reference['negative'] + else + raw_name.downcase.start_with?('not:') + end + { name:, negative:, reference: } end return Post.all if literals.empty? if match_type == 'any' literals.reduce(Post.none) do |posts, literal| - posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative])) + posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative], + reference: literal[:reference])) end else literals.reduce(Post.all) do |posts, literal| - ids = tagged_post_ids_for(locale, literal[:name]) + ids = tagged_post_ids_for(locale, literal[:name], reference: literal[:reference]) if literal[:negative] posts.where.not(id: ids) else @@ -436,8 +513,8 @@ class PostsController < ApplicationController end end - def tag_literal_relation locale, name, negative: - ids = tagged_post_ids_for(locale, name) + def tag_literal_relation locale, name, negative:, reference: nil + ids = tagged_post_ids_for(locale, name, reference:) if negative Post.where.not(id: ids) else @@ -445,7 +522,20 @@ class PostsController < ApplicationController end end - def tagged_post_ids_for locale, name + def tagged_post_ids_for locale, name, reference: nil + if reference + matches = Post.none + if reference['tag_id'] + matches = matches.or(Post.where(id: PostTag.where( + tag_id: reference['tag_id']).select(:post_id))) + end + if reference['external_tag_id'] + matches = matches.or(Post.where(id: PostExternalTag.where( + external_tag_id: reference['external_tag_id']).select(:post_id))) + end + return matches.select(:id) + end + posts_by_internal_tags = Post .joins(tags: :tag_names) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 08539a3..42a478a 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -307,7 +307,7 @@ class TagsController < ApplicationController .joins(:tag_names) .preload(:tag_names, :materials, :deerjikists, :tag_descriptions, parents: [:tag_names, :materials, :deerjikists, :tag_descriptions]) - .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) + .find_by(tag_names: { name:, language_code: locale.language_code }) return render json: TagRepr.base(tag, language_code: locale.language_code) if tag platform, external_name = name.split(':', 2) diff --git a/backend/app/controllers/users_controller.rb b/backend/app/controllers/users_controller.rb index 28c04b9..15604d6 100644 --- a/backend/app/controllers/users_controller.rb +++ b/backend/app/controllers/users_controller.rb @@ -1,45 +1,69 @@ class UsersController < ApplicationController def create + if current_user + return render json: { code: current_user.inheritance_code, + user: current_user.slice(:id, :name, :inheritance_code, :role) } + end + user = nil User.transaction do user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest) attach_ip_address!(user) end + establish_user_session(user) render json: { code: user.inheritance_code, user: user.slice(:id, :name, :inheritance_code, :role) }, status: :created end def verify - user = User.find_by(inheritance_code: params[:code]) + user = if bool?(:restore) && current_user + current_user + else + User.find_by(inheritance_code: params[:code]) + end return render json: { valid: false } unless user return head :forbidden if user.banned? attach_ip_address!(user) + establish_user_session(user) render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } end - def session + def show_session return head :unauthorized unless current_user render json: current_user.slice(:id, :name, :role) end + def csrf + response.headers['Cache-Control'] = 'private, no-store' + render json: { token: form_authenticity_token } + end + + def destroy_session + reset_session + response.headers['Cache-Control'] = 'private, no-store' + head :no_content + end + def renew return head :unauthorized unless current_user user = current_user user.inheritance_code = SecureRandom.uuid user.save! + establish_user_session(user) render json: { code: user.inheritance_code } end def me - user = User.find_by(inheritance_code: params[:code]) - return head :not_found unless user + user = current_user + return head :unauthorized unless user + attach_ip_address!(user) render json: user.slice(:id, :name, :inheritance_code, :role) end diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 1991294..966f511 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -55,9 +55,12 @@ class WikiPagesController < ApplicationController def show locale = resolve_locale! - tag_description = TagDescription.find_by(id: params[:id], language_code: locale.language_code) + reference = TagDescription.find_by(id: params[:id]) + tag_description = reference && TagDescription.find_by( + tag_id: reference.tag_id, language_code: locale.language_code) return render_wiki_page_by_tag_description locale, tag_description if tag_description + return head :not_found if reference tag_group = TagGroup.find_by(id: params[:id]) return render_tag_group locale, tag_group if tag_group @@ -82,6 +85,17 @@ class WikiPagesController < ApplicationController head :not_found end + def tag_reference + locale = resolve_locale! + description = TagDescription.find_by(id: params[:id]) + return head :not_found unless description + + tag = Tag.preload(:tag_names, :materials, :deerjikists, :tag_descriptions, + parents: [:tag_names, :materials, :deerjikists, :tag_descriptions]) + .find(description.tag_id) + render json: TagRepr.base(tag, language_code: locale.language_code) + end + def exists locale = resolve_locale! if TagDescription.exists?(id: params[:id], language_code: locale.language_code) diff --git a/backend/config/application.rb b/backend/config/application.rb index 24390bd..24a0a58 100644 --- a/backend/config/application.rb +++ b/backend/config/application.rb @@ -31,5 +31,14 @@ module Backend # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. config.api_only = true + config.middleware.use ActionDispatch::Cookies + config.session_store :cookie_store, + key: Rails.env.production? ? '__Host-btrc-session' : 'btrc-session', + path: '/', + secure: Rails.env.production?, + httponly: true, + same_site: :lax, + expire_after: 20.years + config.middleware.use ActionDispatch::Session::CookieStore, config.session_options end end diff --git a/backend/config/initializers/filter_parameter_logging.rb b/backend/config/initializers/filter_parameter_logging.rb index c0b717f..1551a7a 100644 --- a/backend/config/initializers/filter_parameter_logging.rb +++ b/backend/config/initializers/filter_parameter_logging.rb @@ -4,5 +4,5 @@ # Use this to limit dissemination of sensitive information. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc -] + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, + :otp, :ssn, :cvv, :cvc, :code, :inheritance_code] diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 2ad1ceb..4e04330 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -56,12 +56,14 @@ Rails.application.routes.draw do member do get :exists get :diff + get :tag, action: :tag_reference end end resources :posts, only: [:index, :show, :create, :update] do collection do get :metadata + get :tag_filters post :bulk get :random get :versions, to: 'post_versions#index' @@ -100,7 +102,9 @@ Rails.application.routes.draw do collection do post :verify get :me - get :session + get :session, action: :show_session + get :csrf + delete :session, action: :destroy_session post 'code/renew', action: :renew end end diff --git a/backend/spec/rails_helper.rb b/backend/spec/rails_helper.rb index 15bee9b..8fae8f1 100644 --- a/backend/spec/rails_helper.rb +++ b/backend/spec/rails_helper.rb @@ -53,6 +53,14 @@ RSpec.configure do |config| config.include AuthHelper, type: :request config.include JsonHelper, type: :request + config.around(:each, type: :request) do |example| + previous = ApplicationController.allow_forgery_protection + ApplicationController.allow_forgery_protection = example.metadata.fetch(:csrf, false) + example.run + ensure + ApplicationController.allow_forgery_protection = previous + end + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_paths = [ Rails.root.join('spec/fixtures') diff --git a/backend/spec/requests/post_locale_filter_identity_spec.rb b/backend/spec/requests/post_locale_filter_identity_spec.rb new file mode 100644 index 0000000..f8d496e --- /dev/null +++ b/backend/spec/requests/post_locale_filter_identity_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +RSpec.describe 'Post filter identity across locales', type: :request do + include_context 'Ainu locale' + + let!(:tag) { create(:tag, primary_name: 'legacy_union_source') } + let!(:external) { create(:external_tag, name: 'shared') } + let!(:internal_post) { create(:post) } + let!(:external_post) { create(:post) } + let!(:other_post) { create(:post) } + + before do + # Legacy data can predate the current prohibition on internal nico: names. + primary_tag_name_for(tag, 'ja').update_columns(name: 'nico:shared') + primary_tag_name_for(tag, 'ain').update!(name: 'シロシ', auto_generated: false) + create(:tag_name, :alias, tag:, name: 'union_alias', language_code: 'ja') + PostTag.create!(post: internal_post, tag:) + PostExternalTag.create!(post: external_post, external_tag: external) + end + + ['nico:shared', 'union_alias', 'not:nico:shared'].each do |literal| + it "preserves the complete match set for #{ literal }" do + get '/posts', params: { locale: 'ja', tags: literal } + expect(response).to have_http_status(:ok) + original_ids = json.fetch('posts').map { _1.fetch('id') } + expected = literal.start_with?('not:') ? [other_post.id] : + [internal_post.id, external_post.id] + expect(original_ids).to match_array(expected) + + get '/posts/tag_filters', params: { + locale: 'ja', target_locale: 'ain-Kana', tags: literal } + expect(response).to have_http_status(:ok) + references = json.fetch('references') + expect(references.first).to include('tag_id' => tag.id, 'external_tag_id' => external.id) + + get '/posts', params: { + locale: 'ain-Kana', tags: references.map { _1.fetch('name') }.join(' '), + tag_refs: references.to_json } + expect(response).to have_http_status(:ok) + expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids) + end + end +end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 06148f4..f0d0ab7 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -900,7 +900,7 @@ RSpec.describe 'Posts API', type: :request do query_count = count_sql_queries do get "/posts/#{ post_record.id }", - headers: { 'X-Transfer-Code' => user.inheritance_code } + headers: cookie_headers(user) end expect(response).to have_http_status(:ok) diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb index 53b9675..2163814 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -4,7 +4,7 @@ RSpec.describe 'Tag description Wiki facade writes', type: :request do include WikiRecords let!(:user) { create_member_user! } let!(:tag) { create(:tag, primary_name: 'description_write') } - let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:headers) { cookie_headers(user) } def stored_state [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model| diff --git a/backend/spec/requests/tag_descriptions_spec.rb b/backend/spec/requests/tag_descriptions_spec.rb index 7fecf45..ceff8fd 100644 --- a/backend/spec/requests/tag_descriptions_spec.rb +++ b/backend/spec/requests/tag_descriptions_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Tag description native API', type: :request do let(:editor) { create(:user, :member, name: 'description editor') } let(:tag_name) { create(:tag_name, name: 'native_description') } let(:tag) { create(:tag, primary_tag_name: tag_name) } - let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } } + let(:headers) { cookie_headers(editor) } let(:endpoint) { "/tags/#{ tag.id }/description" } def stored_state @@ -285,7 +285,7 @@ RSpec.describe 'Tag description native API', type: :request do guest = create(:user, :guest) before_state = stored_state native_write(body: 'forbidden', - request_headers: { 'X-Transfer-Code' => guest.inheritance_code }) + request_headers: cookie_headers(guest)) expect(response).to have_http_status(:forbidden) expect(stored_state).to eq(before_state) end @@ -294,7 +294,7 @@ RSpec.describe 'Tag description native API', type: :request do banned = create(:user, :member, :banned) before_state = stored_state native_write(body: 'forbidden', - request_headers: { 'X-Transfer-Code' => banned.inheritance_code }) + request_headers: cookie_headers(banned)) expect(response).to have_http_status(:forbidden) expect(stored_state).to eq(before_state) end diff --git a/backend/spec/requests/tag_group_locale_spec.rb b/backend/spec/requests/tag_group_locale_spec.rb index 9691b58..23df55b 100644 --- a/backend/spec/requests/tag_group_locale_spec.rb +++ b/backend/spec/requests/tag_group_locale_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do let!(:user) { create_member_user! } let!(:tag) { create(:tag, primary_name: 'japanese_member') } let!(:ainu_name) { add_ainu_name(tag, 'ainu_member') } - let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:headers) { cookie_headers(user) } let(:members) { [{ 'tag_id' => tag.id }] } let(:placements) do [{ 'section_name' => 'section', 'description' => nil, 'children' => [ diff --git a/backend/spec/requests/tag_group_markdown_spec.rb b/backend/spec/requests/tag_group_markdown_spec.rb index 2e12c9e..894c9f5 100644 --- a/backend/spec/requests/tag_group_markdown_spec.rb +++ b/backend/spec/requests/tag_group_markdown_spec.rb @@ -37,7 +37,7 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: : markdown.lines.map(&:chomp).reject(&:empty?)) post '/wiki', params: { title: 'タググループ:再解析', body: }, - headers: { 'X-Transfer-Code' => user.inheritance_code } + headers: cookie_headers(user) expect(response).to have_http_status(:created) parsed = group_rows('tag_groups', name: '再解析').sole expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:) diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb index 846537f..26c63aa 100644 --- a/backend/spec/requests/tag_group_wiki_write_spec.rb +++ b/backend/spec/requests/tag_group_wiki_write_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do let!(:user) { create_member_user! } let!(:tag) { group_tag('洗澡鹿') } let!(:other_tag) { group_tag('伊地知ニジカ') } - let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:headers) { cookie_headers(user) } let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] } let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] } @@ -148,7 +148,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do body = group_markdown(page) travel_to(1.hour.from_now) do expect_noop_group_update(group_id: page.id) do - update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) + update_group(body:, request_headers: cookie_headers(editor)) end end end @@ -212,7 +212,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do it 'records the editor and update time' do editor = create(:user, :member, name: '編輯者') travel_to(Time.current + 1.hour) do - update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) + update_group(request_headers: cookie_headers(editor)) expect(response).to have_http_status(:ok) current = group_rows('tag_groups', id: page.id).sole version = group_version(page.id, 2) @@ -271,7 +271,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do when :guest then create(:user, :guest) when :banned then create(:user, :member, :banned) end - request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } + request_headers = actor ? cookie_headers(actor) : { } before_state = group_state write_group(operation, request_headers) expect(response).to have_http_status(role ? :forbidden : :unauthorized) diff --git a/backend/spec/requests/tag_group_write_integrity_spec.rb b/backend/spec/requests/tag_group_write_integrity_spec.rb index 24e5d99..2a9c82a 100644 --- a/backend/spec/requests/tag_group_write_integrity_spec.rb +++ b/backend/spec/requests/tag_group_write_integrity_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'Tag group write integrity', type: :request do let!(:user) { create_member_user! } let!(:tag) { group_tag('洗澡鹿') } let!(:other_tag) { group_tag('伊地知ニジカ') } - let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:headers) { cookie_headers(user) } let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] } let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] } @@ -141,7 +141,7 @@ RSpec.describe 'Tag group write integrity', type: :request do end let!(:id) { seed_group(name: '更新', members:, placements:) } let!(:editor) { create(:user, :member) } - let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } } + let(:headers) { cookie_headers(editor) } it 'keeps all membership and placement audit fields on a name-only edit' do before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements') diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb index ebca4c7..091d919 100644 --- a/backend/spec/requests/tag_groups_spec.rb +++ b/backend/spec/requests/tag_groups_spec.rb @@ -47,7 +47,7 @@ RSpec.describe 'Tag group native API', type: :request do when :guest then create(:user, :guest) when :banned then create(:user, :member, :banned) end - headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } + headers = actor ? cookie_headers(actor) : { } before_state = group_state if operation == :create post '/tag_groups', params: { }, headers:, as: :json @@ -61,7 +61,7 @@ RSpec.describe 'Tag group native API', type: :request do it "rejects a banned IP for native #{ operation }" do IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current) - headers = { 'X-Transfer-Code' => user.inheritance_code } + headers = cookie_headers(user) before_state = group_state if operation == :create post '/tag_groups', params: { }, headers:, as: :json @@ -77,7 +77,7 @@ RSpec.describe 'Tag group native API', type: :request do let!(:tag) { group_tag('掲載タグ') } let!(:unplaced) { group_tag('未掲載タグ') } let!(:child_id) { seed_group(name: '子', description: '子の説明') } - let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:headers) { cookie_headers(user) } let(:members) do [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }] end @@ -300,7 +300,7 @@ RSpec.describe 'Tag group native API', type: :request do travel_to(1.hour.from_now) do expect_noop_group_update(group_id:) do put "/tag_groups/#{ group_id }", params: synchronised_payload, - headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json + headers: cookie_headers(editor), as: :json end end end diff --git a/backend/spec/requests/tag_resource_ids_spec.rb b/backend/spec/requests/tag_resource_ids_spec.rb index 0e38838..9ab1596 100644 --- a/backend/spec/requests/tag_resource_ids_spec.rb +++ b/backend/spec/requests/tag_resource_ids_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe 'Tag resource ID allocation', type: :request do include WikiRecords let!(:user) { create_member_user! } - let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:headers) { cookie_headers(user) } def create_description name tag = create(:tag, primary_name: name) diff --git a/backend/spec/requests/users_spec.rb b/backend/spec/requests/users_spec.rb index 75183f5..fb088d4 100644 --- a/backend/spec/requests/users_spec.rb +++ b/backend/spec/requests/users_spec.rb @@ -10,10 +10,16 @@ RSpec.describe 'Users', type: :request do end def auth_headers(user) - { 'X-Transfer-Code' => user.inheritance_code } + cookie_headers(user) end describe 'GET /users/session' do + it 'does not accept the retired transfer-code header' do + user = create(:user) + get '/users/session', headers: { 'X-Transfer-Code' => user.inheritance_code } + expect(response).to have_http_status(:unauthorized) + end + it 'requires authentication' do get '/users/session' expect(response).to have_http_status(:unauthorized) @@ -251,17 +257,102 @@ RSpec.describe 'Users', type: :request do end end + describe 'Cookie sessions with CSRF enabled', csrf: true do + def csrf_headers + get '/users/csrf' + expect(response).to have_http_status(:ok) + { 'X-CSRF-Token' => json.fetch('token'), 'Origin' => 'http://www.example.com' } + end + + def establish_session(user) + post '/users/verify', params: { code: user.inheritance_code }, headers: csrf_headers + expect(response).to have_http_status(:ok) + expect(json.fetch('valid')).to be(true) + end + + it 'rejects a write without a token before creating an account' do + expect { post '/users' }.not_to change(User, :count) + expect(response).to have_http_status(:forbidden) + expect(response.headers['X-CSRF-Invalid']).to eq('1') + end + + it 'uses the encrypted persistent Cookie for CSR and SSR session reads' do + user = create(:user) + establish_session(user) + cookie = Array(response.headers['set-cookie']).join + expect(cookie).to include('httponly', 'samesite=lax') + expect(cookie).not_to include(user.inheritance_code) + expires = Time.httpdate(cookie.match(/expires=([^;]+)/i)[1]) + expect(expires).to be > 19.years.from_now + get '/users/session' + expect(json.fetch('id')).to eq(user.id) + expect(response.body).not_to include(user.inheritance_code) + get '/users/me' + expect(json.fetch('inheritance_code')).to eq(user.inheritance_code) + expect(response.headers['Cache-Control']).to include('no-store') + end + + it 'rejects a cross-origin write with an otherwise valid token' do + headers = csrf_headers.merge('Origin' => 'https://other.invalid') + expect { post '/users', headers: }.not_to change(User, :count) + expect(response).to have_http_status(:forbidden) + end + + it 'does not replace an existing Cookie identity during recovery' do + current = create(:user) + old = create(:user) + establish_session(current) + post '/users/verify', params: { code: old.inheritance_code, restore: true }, + headers: csrf_headers + expect(json.fetch('user').fetch('id')).to eq(current.id) + get '/users/session' + expect(json.fetch('id')).to eq(current.id) + end + + it 'allows explicit transfer and refreshes the active session' do + first = create(:user) + second = create(:user) + establish_session(first) + establish_session(second) + get '/users/session' + expect(json.fetch('id')).to eq(second.id) + end + + it 'keeps the renewed session and rejects the previous credential' do + user = create(:user) + establish_session(user) + old_cookie = Array(response.headers['set-cookie']).first.split(';').first + old_code = user.inheritance_code + post '/users/code/renew', headers: csrf_headers + expect(response).to have_http_status(:ok) + expect(json.fetch('code')).not_to eq(old_code) + get '/users/session' + expect(json.fetch('id')).to eq(user.id) + other_browser = ActionDispatch::Integration::Session.new(Rails.application) + other_browser.get('/users/session', headers: { 'Cookie' => old_cookie }) + expect(other_browser.response).to have_http_status(:unauthorized) + end + + it 'clears browser authentication on session deletion' do + establish_session(create(:user)) + delete '/users/session', headers: csrf_headers + expect(response).to have_http_status(:no_content) + get '/users/session' + expect(response).to have_http_status(:unauthorized) + end + end + describe 'GET /users/me' do - it 'returns 404 when code not found' do + it 'requires a session even when a code is supplied in the query' do get '/users/me', params: { code: 'nope' } - expect(response).to have_http_status(:not_found) + expect(response).to have_http_status(:unauthorized) end it 'returns user slice when found' do user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest') - get '/users/me', params: { code: user.inheritance_code } + get '/users/me', headers: cookie_headers(user) expect(response).to have_http_status(:ok) expect(json['id']).to eq(user.id) diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb index 55c2f35..3c9fbcd 100644 --- a/backend/spec/requests/wiki_conflict_spec.rb +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do let!(:user) { create_member_user! } def auth_headers user - { 'X-Transfer-Code' => user.inheritance_code } + cookie_headers(user) end it 'returns 409 when base_revision_id is stale' do diff --git a/backend/spec/requests/wiki_creation_freeze_spec.rb b/backend/spec/requests/wiki_creation_freeze_spec.rb index a51b8ac..028cb12 100644 --- a/backend/spec/requests/wiki_creation_freeze_spec.rb +++ b/backend/spec/requests/wiki_creation_freeze_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do include WikiRecords let!(:user) { create_member_user! } - let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:headers) { cookie_headers(user) } context 'existing description write protection' do @@ -18,7 +18,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do it "rejects #{ role || 'anonymous' } without changing either history" do actor = role && create(:user, role == :banned ? :member : role) actor.update!(banned_at: Time.current) if role == :banned - request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } + request_headers = actor ? cookie_headers(actor) : { } before_state = group_state put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' }, headers: request_headers diff --git a/backend/spec/requests/wiki_locale_spec.rb b/backend/spec/requests/wiki_locale_spec.rb index 9e8998d..2a190aa 100644 --- a/backend/spec/requests/wiki_locale_spec.rb +++ b/backend/spec/requests/wiki_locale_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do let!(:user) { create_member_user! } let!(:tag) { create(:tag, primary_name: 'japanese_title') } let!(:ainu_name) { add_ainu_name(tag, 'ainu_title') } - let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:headers) { cookie_headers(user) } let!(:japanese) do TagDescriptionOperation.create!( Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user) diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 12663c0..9c8fb4e 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -6,7 +6,7 @@ require 'securerandom' RSpec.describe 'Wiki API', type: :request do include WikiRecords def auth_headers(user) - { 'X-Transfer-Code' => user.inheritance_code } + cookie_headers(user) end let!(:user) { create_member_user! } @@ -80,7 +80,7 @@ RSpec.describe 'Wiki API', type: :request do let(:guest) { create(:user, role: 'guest') } def auth_headers(user) - { 'X-Transfer-Code' => user.inheritance_code } + cookie_headers(user) end context 'when not logged in' do @@ -126,7 +126,7 @@ RSpec.describe 'Wiki API', type: :request do let(:guest) { create(:user, role: 'guest', inheritance_code: SecureRandom.hex(16)) } def auth_headers(user) - { 'X-Transfer-Code' => user.inheritance_code } + cookie_headers(user) end let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') } diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb index f6f3011..81f9435 100644 --- a/backend/spec/requests/wiki_title_collision_spec.rb +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'Wiki title collision', type: :request do let!(:user) { create_member_user! } def auth_headers user - { 'X-Transfer-Code' => user.inheritance_code } + cookie_headers(user) end def create_wiki_page title:, body: diff --git a/backend/spec/requests/wiki_unmigrated_contract_spec.rb b/backend/spec/requests/wiki_unmigrated_contract_spec.rb index 869be12..300c858 100644 --- a/backend/spec/requests/wiki_unmigrated_contract_spec.rb +++ b/backend/spec/requests/wiki_unmigrated_contract_spec.rb @@ -17,7 +17,7 @@ RSpec.describe 'Removed standalone Wiki resources', type: :request do user before_state = group_state post '/wiki', params: { title:, body: 'body' }, - headers: { 'X-Transfer-Code' => user.inheritance_code } + headers: cookie_headers(user) expect(response).to have_http_status(:unprocessable_entity) expect(group_state).to eq(before_state) end diff --git a/backend/spec/support/auth_helper.rb b/backend/spec/support/auth_helper.rb index a13f63a..c292561 100644 --- a/backend/spec/support/auth_helper.rb +++ b/backend/spec/support/auth_helper.rb @@ -1,4 +1,16 @@ module AuthHelper + def cookie_headers(user) + env = Rails.application.env_config.merge(Rack::MockRequest.env_for('/')) + endpoint = lambda do |request_env| + ActionDispatch::Request.new(request_env).session[:inheritance_code] = user.inheritance_code + [200, {}, []] + end + store = ActionDispatch::Session::CookieStore.new( + endpoint, Rails.application.config.session_options) + headers = ActionDispatch::Cookies.new(store).call(env)[1] + { 'Cookie' => Array(headers['set-cookie']).map { _1.split(';').first }.join('; ') } + end + def sign_in_as(user) allow_any_instance_of(ApplicationController) .to receive(:current_user).and_return(user) diff --git a/frontend/server.mjs b/frontend/server.mjs index 9d88f62..f4e09b1 100644 --- a/frontend/server.mjs +++ b/frontend/server.mjs @@ -1,7 +1,7 @@ import { createReadStream } from 'node:fs' import { readFile, stat } from 'node:fs/promises' -import { createServer } from 'node:http' -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' +import { createServer, request as requestHTTP } from 'node:http' +import { request as requestHTTPS } from 'node:https' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -11,39 +11,6 @@ const root = fileURLToPath (new URL ('./dist/client/', import.meta.url)) const template = await readFile (path.join (root, 'index.html'), 'utf8') const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin const apiBaseURL = process.env.SSR_API_BASE_URL ?? 'http://127.0.0.1:3002' -const sessionSecret = process.env.SSR_COOKIE_SECRET -if (process.env.NODE_ENV === 'production' && (!sessionSecret || sessionSecret.length < 32)) - throw new Error ('SSR_COOKIE_SECRET must contain at least 32 characters in production') -const sessionKey = createHash ('sha256').update (sessionSecret ?? randomBytes (32)).digest () -const cookieName = '__Host-btrc-session' -const sessionSeconds = 7 * 24 * 60 * 60 - -const encodeSession = code => { - const iv = randomBytes (12) - const cipher = createCipheriv ('aes-256-gcm', sessionKey, iv) - const payload = JSON.stringify ({ code, expires: Date.now () + sessionSeconds * 1000 }) - const encrypted = Buffer.concat ([cipher.update (payload, 'utf8'), cipher.final ()]) - return Buffer.concat ([iv, cipher.getAuthTag (), encrypted]).toString ('base64url') -} - -const decodeSession = cookie => { - try - { - const token = cookie?.split (';').map (part => part.trim ()) - .find (part => part.startsWith (`${cookieName}=`))?.slice (cookieName.length + 1) - if (!token || token.length > 2048) return undefined - const payload = Buffer.from (token, 'base64url') - const decipher = createDecipheriv ('aes-256-gcm', sessionKey, payload.subarray (0, 12)) - decipher.setAuthTag (payload.subarray (12, 28)) - const data = JSON.parse (Buffer.concat ([ - decipher.update (payload.subarray (28)), decipher.final ()]).toString ('utf8')) - return typeof data.code === 'string' && data.expires > Date.now () ? data.code : undefined - } - catch - { - return undefined - } -} const types = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg', @@ -57,38 +24,32 @@ if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN) const server = createServer (async (request, response) => { response.setHeader ('Cache-Control', 'private, no-store') const forwardedFor = request.headers['x-forwarded-for'] ?? request.socket.remoteAddress - if (request.url === '/_ssr/session') + if (request.url === '/api' || request.url.startsWith ('/api/')) { - if (request.method !== 'POST' || request.headers.origin !== origin) - { - response.writeHead (403).end () - return - } - const code = request.headers['x-transfer-code'] - if (typeof code !== 'string' || code.length > 512 || code.length === 0) - { - response.writeHead (400).end () - return - } - try - { - const verified = await fetch (new URL ('users/session', apiBaseURL.replace (/\/?$/, '/')), { - headers: { 'X-Transfer-Code': code, 'X-Forwarded-For': forwardedFor }, - signal: AbortSignal.timeout (10_000) }) - if (!verified.ok) - { - response.writeHead (verified.status === 401 || verified.status === 403 - ? verified.status : 503).end () - return - } - response.setHeader ('Set-Cookie', `${cookieName}=${encodeSession (code)}; ` - + `Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${sessionSeconds}`) - response.writeHead (204).end () - } - catch - { - response.writeHead (503).end () - } + const address = new URL (request.url, origin) + const target = new URL (apiBaseURL) + target.pathname = target.pathname.replace (/\/$/, '') + address.pathname.slice (4) + target.search = address.search + const send = target.protocol === 'https:' ? requestHTTPS : requestHTTP + const upstream = send (target, { + method: request.method, + headers: { ...request.headers, host: target.host, + 'x-forwarded-host': new URL (origin).host, + 'x-forwarded-proto': new URL (origin).protocol.replace (':', ''), + 'x-forwarded-for': forwardedFor } }, result => { + response.writeHead (result.statusCode ?? 502, result.headers) + result.on ('error', () => response.destroy ()) + result.pipe (response) + }) + upstream.setTimeout (10_000, () => upstream.destroy (new Error ('API timeout'))) + upstream.on ('error', () => { + if (response.headersSent) + response.destroy () + else + response.writeHead (503).end () + }) + request.on ('aborted', () => upstream.destroy ()) + request.pipe (upstream) return } if (!['GET', 'HEAD'].includes (request.method)) @@ -124,7 +85,9 @@ const server = createServer (async (request, response) => { return } const result = await render (url.pathname + url.search, origin, apiBaseURL, { - transferCode: decodeSession (request.headers.cookie), forwardedFor }) + cookie: request.headers.cookie, forwardedFor }) + if (result?.setCookies?.length > 0) + response.setHeader ('Set-Cookie', result.setCookies) if (result?.location != null) { response.writeHead (result.status, { Location: result.location }).end () diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3cf0976..5a3b511 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,11 +16,14 @@ import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' import PageHead from '@/components/common/PageHead' import { Toaster } from '@/components/ui/toaster' -import { apiPost, isApiError } from '@/lib/api' +import { isApiError } from '@/lib/api' +import { initialiseUserSession } from '@/lib/users' import { prepareHelpPage } from '@/lib/help' import { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath, unlocalisedPath } from '@/lib/locales' import { localesKeys, sessionKeys } from '@/lib/queryKeys' +import { switchPostFilterLocale } from '@/lib/posts' +import { switchWikiLocale } from '@/lib/wiki' import { useLocale } from '@/lib/useLocale' import { applyClientAnimationMode, applyClientAppearance, @@ -42,7 +45,7 @@ import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { RouteMatch } from 'react-router-dom' import type { ClientAnimationMode } from '@/lib/settings' -import type { AvailableLocale } from '@/lib/locales' +import type { AvailableLocale, LocaleCode } from '@/lib/locales' import type { PageRoute } from '@/lib/routeLocales' import type { User } from '@/types' @@ -101,10 +104,12 @@ export const pageRoutes = ( ): PageRoute[] => [ { path: '', element: , handle: { redirectTo: '/posts' } }, - { path: 'posts', element: }, + { path: 'posts', element: , + handle: { switchLocale: switchPostFilterLocale } }, { path: 'posts/new', element: , handle: { robots: 'noindex' } }, - { path: 'posts/search', element: }, + { path: 'posts/search', element: , + handle: { switchLocale: switchPostFilterLocale } }, { path: 'posts/:id', element: }, { path: 'posts/changes', element: }, { path: 'tags', element: }, @@ -122,13 +127,17 @@ export const pageRoutes = ( handle: { robots: 'noindex', authentication: 'user' } }, { path: ':id', element: }] }, { path: 'wiki', element: }, - { path: 'wiki/:title', element: }, + { path: 'wiki/:title', element: , + handle: { switchLocale: switchWikiLocale } }, { path: 'wiki/new', element: , handle: { robots: 'noindex' } }, { path: 'wiki/:id/edit', element: , - handle: { robots: 'noindex' } }, - { path: 'wiki/:id/diff', element: }, - { path: 'wiki/changes', element: }, + handle: { robots: 'noindex', + switchLocale: request => switchWikiLocale (request, 'edit') } }, + { path: 'wiki/:id/diff', element: , + handle: { switchLocale: request => switchWikiLocale (request, 'diff') } }, + { path: 'wiki/changes', element: , + handle: { switchLocale: request => switchWikiLocale (request, 'history') } }, { path: 'help', element: }, { path: 'help/changes', element: }, { path: 'help/:identifier/diff', element: }, @@ -177,7 +186,15 @@ const PostDetailRoute = ({ user }: { user: User | null }) => { const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { const client = useQueryClient () const location = useLocation () - const robots = resolvePageRoute (location.pathname)?.route.handle?.robots + const locale = useLocale () + const route = resolvePageRoute (location.pathname) + const robots = route?.route.handle?.robots + const switchLocale = async (targetLocale: LocaleCode): Promise => { + const resolve = route?.route.handle?.switchLocale + return (resolve != null && route != null) + ? resolve ({ location, params: route.params, locale, targetLocale }) + : switchLocalePath (location, targetLocale) + } const [user, setUser] = useState ( () => client.getQueryData (sessionKeys.current) ?? null) const [status, setStatus] = useState (200) @@ -213,36 +230,21 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { }, [animationMode]) useEffect (() => { - const createUser = async () => { - const data = await apiPost<{ code: string; user: User }> ('/users') - if (data.code) - { - localStorage.setItem ('user_code', data.code) - setUser (data.user) - } - } - - const code = localStorage.getItem ('user_code') - if (code) + let cancelled = false + void (async () => { + try { - void (async () => { - try - { - const data = await apiPost<{ valid: boolean; user: User }> ('/users/verify', { code }) - if (data.valid) - setUser (data.user) - else - await createUser () - } - catch (err) - { - if (isApiError (err)) - setStatus (err.response?.status ?? 200) - } - }) () + const user = await initialiseUserSession () + if (!cancelled) + setUser (user) } - else - createUser () + catch (error) + { + if (!cancelled && isApiError (error)) + setStatus (error.response?.status ?? 503) + } + }) () + return () => { cancelled = true } }, []) useEffect (() => { @@ -295,7 +297,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { layout={animationMode === 'off' ? false : 'position'} transition={{ layout: appLayoutTransition }} className="relative flex h-dvh w-full flex-col overflow-y-hidden"> - + { const { locale } = useParams () const location = useLocation () const { i18n } = useTranslation () - const { data, isPending, isError, refetch } = useQuery ({ + const { data, isError } = useQuery ({ queryKey: localesKeys.index, queryFn: fetchLocales, - staleTime: 0, - refetchOnMount: 'always', - refetchOnWindowFocus: 'always', - refetchOnReconnect: 'always' }) + staleTime: 60_000, + refetchInterval: 60_000, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true }) const activeLocale = (isLocaleCode (locale) && data?.locales.some (entry => entry.code === locale)) @@ -335,16 +338,12 @@ const LocaleRoute: FC = () => { void i18n.changeLanguage (activeLocale) }, [activeLocale, i18n]) - useEffect (() => { - void refetch () - }, [location.key, refetch]) - const redirect = legacyLocalePath (location) if (!(isLocaleCode (locale)) && redirect == null) return - if (isError) + if (isError && data == null) return - if (isPending) + if (data == null) return null if (isLocaleCode (locale)) { diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index bbada0c..9a0033f 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -10,6 +10,7 @@ import Separator from '@/components/MenuSeparator' import PrefetchLink from '@/components/PrefetchLink' import TopNavUser from '@/components/TopNavUser' import TranslatedText from '@/components/TranslatedText' +import { toast } from '@/components/ui/use-toast' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { isLocaleCode, localeFromPath, @@ -27,10 +28,13 @@ import { fetchWikiPage } from '@/lib/wiki' import type { FC, MouseEvent } from 'react' -import type { AvailableLocale } from '@/lib/locales' +import type { AvailableLocale, LocaleCode } from '@/lib/locales' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' -type Props = { user: User | null; locales: AvailableLocale[] } +type Props = { + user: User | null + locales: AvailableLocale[] + resolveLocaleSwitch?: (locale: LocaleCode) => Promise } type NavDensity = 'auto' | 'comfortable' | 'compact' @@ -125,12 +129,21 @@ export const menuOutline = ( } -const TopNav: FC = ({ user, locales }) => { +const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { const { message, label } = useUITranslation ('shared') const locale = useLocale () const localeName = locales.find (entry => entry.code === locale)?.name const navigate = useLocaleNavigate () const location = useLocation () + const currentLocation = useRef (location) + currentLocation.current = location + const localeSwitchRequest = useRef (0) + const [switchingLocale, setSwitchingLocale] = useState (false) + useEffect (() => { + ++localeSwitchRequest.current + setSwitchingLocale (false) + return () => { ++localeSwitchRequest.current } + }, [location.key]) const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const animationsOff = animationMode === 'off' @@ -380,13 +393,34 @@ const TopNav: FC = ({ user, locales }) => {