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 }) => {