このコミットが含まれているのは:
@@ -1,4 +1,7 @@
|
|||||||
class ApplicationController < ActionController::API
|
class ApplicationController < ActionController::API
|
||||||
|
include ActionController::Cookies
|
||||||
|
include ActionController::RequestForgeryProtection
|
||||||
|
|
||||||
class UnsupportedLocale < StandardError
|
class UnsupportedLocale < StandardError
|
||||||
;
|
;
|
||||||
end
|
end
|
||||||
@@ -11,18 +14,39 @@ class ApplicationController < ActionController::API
|
|||||||
before_action :authenticate_user
|
before_action :authenticate_user
|
||||||
before_action :reject_banned_user!
|
before_action :reject_banned_user!
|
||||||
before_action :validate_explicit_locale!
|
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
|
def current_user = @current_user
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def reject_invalid_csrf
|
||||||
|
response.headers['X-CSRF-Invalid'] = '1'
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
|
head :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
def authenticate_user
|
def authenticate_user
|
||||||
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
|
code = session[:inheritance_code]
|
||||||
return if code.blank?
|
return if code.blank?
|
||||||
|
|
||||||
@current_user = User.find_by(inheritance_code: code)
|
@current_user = User.find_by(inheritance_code: code)
|
||||||
end
|
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
|
def bool? key, default: false
|
||||||
return default if params[key].nil?
|
return default if params[key].nil?
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class NicoTagsController < ApplicationController
|
|||||||
|
|
||||||
q =
|
q =
|
||||||
ExternalTag
|
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')
|
'ON post_tag_max.external_tag_id = external_tags.id')
|
||||||
.includes(linked_tags: [:tag_names, :tag_descriptions])
|
.includes(linked_tags: [:tag_names, :tag_descriptions])
|
||||||
if name
|
if name
|
||||||
@@ -37,7 +37,7 @@ class NicoTagsController < ApplicationController
|
|||||||
linked_tag_ids =
|
linked_tag_ids =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_names)
|
.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 }%")
|
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
|
||||||
.pluck(:id)
|
.pluck(:id)
|
||||||
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
|
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
|
||||||
@@ -45,7 +45,7 @@ class NicoTagsController < ApplicationController
|
|||||||
end
|
end
|
||||||
if link_status.in?(['linked', 'unlinked'])
|
if link_status.in?(['linked', 'unlinked'])
|
||||||
exists_sql =
|
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)'
|
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
|
||||||
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
|
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -400,8 +400,76 @@ class PostsController < ApplicationController
|
|||||||
render_post_form_record_invalid e.record
|
render_post_form_record_invalid e.record
|
||||||
end
|
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
|
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
|
def filtered_posts locale
|
||||||
tag_names = params[:tags].to_s.split
|
tag_names = params[:tags].to_s.split
|
||||||
match_type = params[:match]
|
match_type = params[:match]
|
||||||
@@ -413,20 +481,29 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def filter_posts_by_tags locale, tag_names, match_type
|
def filter_posts_by_tags locale, tag_names, match_type
|
||||||
literals = tag_names.map do |raw_name|
|
references = tag_filter_references(tag_names)
|
||||||
{ name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first,
|
literals = tag_names.each_with_index.map do |raw_name, index|
|
||||||
negative: raw_name.downcase.start_with?('not:') }
|
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
|
end
|
||||||
|
|
||||||
return Post.all if literals.empty?
|
return Post.all if literals.empty?
|
||||||
|
|
||||||
if match_type == 'any'
|
if match_type == 'any'
|
||||||
literals.reduce(Post.none) do |posts, literal|
|
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
|
end
|
||||||
else
|
else
|
||||||
literals.reduce(Post.all) do |posts, literal|
|
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]
|
if literal[:negative]
|
||||||
posts.where.not(id: ids)
|
posts.where.not(id: ids)
|
||||||
else
|
else
|
||||||
@@ -436,8 +513,8 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_literal_relation locale, name, negative:
|
def tag_literal_relation locale, name, negative:, reference: nil
|
||||||
ids = tagged_post_ids_for(locale, name)
|
ids = tagged_post_ids_for(locale, name, reference:)
|
||||||
if negative
|
if negative
|
||||||
Post.where.not(id: ids)
|
Post.where.not(id: ids)
|
||||||
else
|
else
|
||||||
@@ -445,7 +522,20 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
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 =
|
posts_by_internal_tags =
|
||||||
Post
|
Post
|
||||||
.joins(tags: :tag_names)
|
.joins(tags: :tag_names)
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ class TagsController < ApplicationController
|
|||||||
.joins(:tag_names)
|
.joins(:tag_names)
|
||||||
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
|
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
|
||||||
parents: [: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
|
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||||
|
|
||||||
platform, external_name = name.split(':', 2)
|
platform, external_name = name.split(':', 2)
|
||||||
|
|||||||
@@ -1,45 +1,69 @@
|
|||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
def create
|
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 = nil
|
||||||
User.transaction do
|
User.transaction do
|
||||||
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
|
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
|
||||||
attach_ip_address!(user)
|
attach_ip_address!(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
establish_user_session(user)
|
||||||
render json: { code: user.inheritance_code,
|
render json: { code: user.inheritance_code,
|
||||||
user: user.slice(:id, :name, :inheritance_code, :role) },
|
user: user.slice(:id, :name, :inheritance_code, :role) },
|
||||||
status: :created
|
status: :created
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify
|
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 render json: { valid: false } unless user
|
||||||
return head :forbidden if user.banned?
|
return head :forbidden if user.banned?
|
||||||
|
|
||||||
attach_ip_address!(user)
|
attach_ip_address!(user)
|
||||||
|
establish_user_session(user)
|
||||||
|
|
||||||
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def session
|
def show_session
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
render json: current_user.slice(:id, :name, :role)
|
render json: current_user.slice(:id, :name, :role)
|
||||||
end
|
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
|
def renew
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
user = current_user
|
user = current_user
|
||||||
user.inheritance_code = SecureRandom.uuid
|
user.inheritance_code = SecureRandom.uuid
|
||||||
user.save!
|
user.save!
|
||||||
|
establish_user_session(user)
|
||||||
render json: { code: user.inheritance_code }
|
render json: { code: user.inheritance_code }
|
||||||
end
|
end
|
||||||
|
|
||||||
def me
|
def me
|
||||||
user = User.find_by(inheritance_code: params[:code])
|
user = current_user
|
||||||
return head :not_found unless user
|
return head :unauthorized unless user
|
||||||
|
|
||||||
|
attach_ip_address!(user)
|
||||||
render json: user.slice(:id, :name, :inheritance_code, :role)
|
render json: user.slice(:id, :name, :inheritance_code, :role)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,12 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
locale = resolve_locale!
|
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 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])
|
tag_group = TagGroup.find_by(id: params[:id])
|
||||||
return render_tag_group locale, tag_group if tag_group
|
return render_tag_group locale, tag_group if tag_group
|
||||||
@@ -82,6 +85,17 @@ class WikiPagesController < ApplicationController
|
|||||||
head :not_found
|
head :not_found
|
||||||
end
|
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
|
def exists
|
||||||
locale = resolve_locale!
|
locale = resolve_locale!
|
||||||
if TagDescription.exists?(id: params[:id], language_code: locale.language_code)
|
if TagDescription.exists?(id: params[:id], language_code: locale.language_code)
|
||||||
|
|||||||
@@ -31,5 +31,14 @@ module Backend
|
|||||||
# Middleware like session, flash, cookies can be added back manually.
|
# Middleware like session, flash, cookies can be added back manually.
|
||||||
# Skip views, helpers and assets when generating a new resource.
|
# Skip views, helpers and assets when generating a new resource.
|
||||||
config.api_only = true
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
# Use this to limit dissemination of sensitive information.
|
# Use this to limit dissemination of sensitive information.
|
||||||
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
||||||
Rails.application.config.filter_parameters += [
|
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]
|
||||||
|
|||||||
@@ -56,12 +56,14 @@ Rails.application.routes.draw do
|
|||||||
member do
|
member do
|
||||||
get :exists
|
get :exists
|
||||||
get :diff
|
get :diff
|
||||||
|
get :tag, action: :tag_reference
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :posts, only: [:index, :show, :create, :update] do
|
resources :posts, only: [:index, :show, :create, :update] do
|
||||||
collection do
|
collection do
|
||||||
get :metadata
|
get :metadata
|
||||||
|
get :tag_filters
|
||||||
post :bulk
|
post :bulk
|
||||||
get :random
|
get :random
|
||||||
get :versions, to: 'post_versions#index'
|
get :versions, to: 'post_versions#index'
|
||||||
@@ -100,7 +102,9 @@ Rails.application.routes.draw do
|
|||||||
collection do
|
collection do
|
||||||
post :verify
|
post :verify
|
||||||
get :me
|
get :me
|
||||||
get :session
|
get :session, action: :show_session
|
||||||
|
get :csrf
|
||||||
|
delete :session, action: :destroy_session
|
||||||
post 'code/renew', action: :renew
|
post 'code/renew', action: :renew
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ RSpec.configure do |config|
|
|||||||
config.include AuthHelper, type: :request
|
config.include AuthHelper, type: :request
|
||||||
config.include JsonHelper, 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
|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
|
||||||
config.fixture_paths = [
|
config.fixture_paths = [
|
||||||
Rails.root.join('spec/fixtures')
|
Rails.root.join('spec/fixtures')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -900,7 +900,7 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
query_count =
|
query_count =
|
||||||
count_sql_queries do
|
count_sql_queries do
|
||||||
get "/posts/#{ post_record.id }",
|
get "/posts/#{ post_record.id }",
|
||||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
headers: cookie_headers(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ RSpec.describe 'Tag description Wiki facade writes', type: :request do
|
|||||||
include WikiRecords
|
include WikiRecords
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:tag) { create(:tag, primary_name: 'description_write') }
|
let!(:tag) { create(:tag, primary_name: 'description_write') }
|
||||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
let(:headers) { cookie_headers(user) }
|
||||||
|
|
||||||
def stored_state
|
def stored_state
|
||||||
[Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model|
|
[Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
let(:editor) { create(:user, :member, name: 'description editor') }
|
let(:editor) { create(:user, :member, name: 'description editor') }
|
||||||
let(:tag_name) { create(:tag_name, name: 'native_description') }
|
let(:tag_name) { create(:tag_name, name: 'native_description') }
|
||||||
let(:tag) { create(:tag, primary_tag_name: tag_name) }
|
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" }
|
let(:endpoint) { "/tags/#{ tag.id }/description" }
|
||||||
|
|
||||||
def stored_state
|
def stored_state
|
||||||
@@ -285,7 +285,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
guest = create(:user, :guest)
|
guest = create(:user, :guest)
|
||||||
before_state = stored_state
|
before_state = stored_state
|
||||||
native_write(body: 'forbidden',
|
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(response).to have_http_status(:forbidden)
|
||||||
expect(stored_state).to eq(before_state)
|
expect(stored_state).to eq(before_state)
|
||||||
end
|
end
|
||||||
@@ -294,7 +294,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
banned = create(:user, :member, :banned)
|
banned = create(:user, :member, :banned)
|
||||||
before_state = stored_state
|
before_state = stored_state
|
||||||
native_write(body: 'forbidden',
|
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(response).to have_http_status(:forbidden)
|
||||||
expect(stored_state).to eq(before_state)
|
expect(stored_state).to eq(before_state)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
|||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:tag) { create(:tag, primary_name: 'japanese_member') }
|
let!(:tag) { create(:tag, primary_name: 'japanese_member') }
|
||||||
let!(:ainu_name) { add_ainu_name(tag, 'ainu_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(:members) { [{ 'tag_id' => tag.id }] }
|
||||||
let(:placements) do
|
let(:placements) do
|
||||||
[{ 'section_name' => 'section', 'description' => nil, 'children' => [
|
[{ 'section_name' => 'section', 'description' => nil, 'children' => [
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
|
|||||||
markdown.lines.map(&:chomp).reject(&:empty?))
|
markdown.lines.map(&:chomp).reject(&:empty?))
|
||||||
|
|
||||||
post '/wiki', params: { title: 'タググループ:再解析', body: },
|
post '/wiki', params: { title: 'タググループ:再解析', body: },
|
||||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
headers: cookie_headers(user)
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
parsed = group_rows('tag_groups', name: '再解析').sole
|
parsed = group_rows('tag_groups', name: '再解析').sole
|
||||||
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
|
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:tag) { group_tag('洗澡鹿') }
|
let!(:tag) { group_tag('洗澡鹿') }
|
||||||
let!(:other_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(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
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)
|
body = group_markdown(page)
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id: page.id) 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
|
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
|
it 'records the editor and update time' do
|
||||||
editor = create(:user, :member, name: '編輯者')
|
editor = create(:user, :member, name: '編輯者')
|
||||||
travel_to(Time.current + 1.hour) do
|
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)
|
expect(response).to have_http_status(:ok)
|
||||||
current = group_rows('tag_groups', id: page.id).sole
|
current = group_rows('tag_groups', id: page.id).sole
|
||||||
version = group_version(page.id, 2)
|
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 :guest then create(:user, :guest)
|
||||||
when :banned then create(:user, :member, :banned)
|
when :banned then create(:user, :member, :banned)
|
||||||
end
|
end
|
||||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
request_headers = actor ? cookie_headers(actor) : { }
|
||||||
before_state = group_state
|
before_state = group_state
|
||||||
write_group(operation, request_headers)
|
write_group(operation, request_headers)
|
||||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:tag) { group_tag('洗澡鹿') }
|
let!(:tag) { group_tag('洗澡鹿') }
|
||||||
let!(:other_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(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
end
|
end
|
||||||
let!(:id) { seed_group(name: '更新', members:, placements:) }
|
let!(:id) { seed_group(name: '更新', members:, placements:) }
|
||||||
let!(:editor) { create(:user, :member) }
|
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
|
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')
|
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
when :guest then create(:user, :guest)
|
when :guest then create(:user, :guest)
|
||||||
when :banned then create(:user, :member, :banned)
|
when :banned then create(:user, :member, :banned)
|
||||||
end
|
end
|
||||||
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
headers = actor ? cookie_headers(actor) : { }
|
||||||
before_state = group_state
|
before_state = group_state
|
||||||
if operation == :create
|
if operation == :create
|
||||||
post '/tag_groups', params: { }, headers:, as: :json
|
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
|
it "rejects a banned IP for native #{ operation }" do
|
||||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
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
|
before_state = group_state
|
||||||
if operation == :create
|
if operation == :create
|
||||||
post '/tag_groups', params: { }, headers:, as: :json
|
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!(:tag) { group_tag('掲載タグ') }
|
||||||
let!(:unplaced) { group_tag('未掲載タグ') }
|
let!(:unplaced) { group_tag('未掲載タグ') }
|
||||||
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
|
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
|
||||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
let(:headers) { cookie_headers(user) }
|
||||||
let(:members) do
|
let(:members) do
|
||||||
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
|
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
|
||||||
end
|
end
|
||||||
@@ -300,7 +300,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id:) do
|
expect_noop_group_update(group_id:) do
|
||||||
put "/tag_groups/#{ group_id }", params: synchronised_payload,
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
|||||||
RSpec.describe 'Tag resource ID allocation', type: :request do
|
RSpec.describe 'Tag resource ID allocation', type: :request do
|
||||||
include WikiRecords
|
include WikiRecords
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
let(:headers) { cookie_headers(user) }
|
||||||
|
|
||||||
def create_description name
|
def create_description name
|
||||||
tag = create(:tag, primary_name: name)
|
tag = create(:tag, primary_name: name)
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ RSpec.describe 'Users', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def auth_headers(user)
|
def auth_headers(user)
|
||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
cookie_headers(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /users/session' do
|
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
|
it 'requires authentication' do
|
||||||
get '/users/session'
|
get '/users/session'
|
||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
@@ -251,17 +257,102 @@ RSpec.describe 'Users', type: :request do
|
|||||||
end
|
end
|
||||||
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
|
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' }
|
get '/users/me', params: { code: 'nope' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns user slice when found' do
|
it 'returns user slice when found' do
|
||||||
user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest')
|
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(response).to have_http_status(:ok)
|
||||||
expect(json['id']).to eq(user.id)
|
expect(json['id']).to eq(user.id)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
|||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
def auth_headers user
|
def auth_headers user
|
||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
cookie_headers(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 409 when base_revision_id is stale' do
|
it 'returns 409 when base_revision_id is stale' do
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do
|
|||||||
include WikiRecords
|
include WikiRecords
|
||||||
|
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
let(:headers) { cookie_headers(user) }
|
||||||
|
|
||||||
|
|
||||||
context 'existing description write protection' do
|
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
|
it "rejects #{ role || 'anonymous' } without changing either history" do
|
||||||
actor = role && create(:user, role == :banned ? :member : role)
|
actor = role && create(:user, role == :banned ? :member : role)
|
||||||
actor.update!(banned_at: Time.current) if role == :banned
|
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
|
before_state = group_state
|
||||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
|
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
|
||||||
headers: request_headers
|
headers: request_headers
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
|||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:tag) { create(:tag, primary_name: 'japanese_title') }
|
let!(:tag) { create(:tag, primary_name: 'japanese_title') }
|
||||||
let!(:ainu_name) { add_ainu_name(tag, 'ainu_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
|
let!(:japanese) do
|
||||||
TagDescriptionOperation.create!(
|
TagDescriptionOperation.create!(
|
||||||
Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user)
|
Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ require 'securerandom'
|
|||||||
RSpec.describe 'Wiki API', type: :request do
|
RSpec.describe 'Wiki API', type: :request do
|
||||||
include WikiRecords
|
include WikiRecords
|
||||||
def auth_headers(user)
|
def auth_headers(user)
|
||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
cookie_headers(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
@@ -80,7 +80,7 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
let(:guest) { create(:user, role: 'guest') }
|
let(:guest) { create(:user, role: 'guest') }
|
||||||
|
|
||||||
def auth_headers(user)
|
def auth_headers(user)
|
||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
cookie_headers(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when not logged in' do
|
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)) }
|
let(:guest) { create(:user, role: 'guest', inheritance_code: SecureRandom.hex(16)) }
|
||||||
|
|
||||||
def auth_headers(user)
|
def auth_headers(user)
|
||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
cookie_headers(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
|
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki title collision', type: :request do
|
|||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
def auth_headers user
|
def auth_headers user
|
||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
cookie_headers(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_wiki_page title:, body:
|
def create_wiki_page title:, body:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RSpec.describe 'Removed standalone Wiki resources', type: :request do
|
|||||||
user
|
user
|
||||||
before_state = group_state
|
before_state = group_state
|
||||||
post '/wiki', params: { title:, body: 'body' },
|
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(response).to have_http_status(:unprocessable_entity)
|
||||||
expect(group_state).to eq(before_state)
|
expect(group_state).to eq(before_state)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
module AuthHelper
|
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)
|
def sign_in_as(user)
|
||||||
allow_any_instance_of(ApplicationController)
|
allow_any_instance_of(ApplicationController)
|
||||||
.to receive(:current_user).and_return(user)
|
.to receive(:current_user).and_return(user)
|
||||||
|
|||||||
+30
-67
@@ -1,7 +1,7 @@
|
|||||||
import { createReadStream } from 'node:fs'
|
import { createReadStream } from 'node:fs'
|
||||||
import { readFile, stat } from 'node:fs/promises'
|
import { readFile, stat } from 'node:fs/promises'
|
||||||
import { createServer } from 'node:http'
|
import { createServer, request as requestHTTP } from 'node:http'
|
||||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
import { request as requestHTTPS } from 'node:https'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
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 template = await readFile (path.join (root, 'index.html'), 'utf8')
|
||||||
const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin
|
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 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 = {
|
const types = {
|
||||||
'.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
|
'.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
|
||||||
'.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg',
|
'.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) => {
|
const server = createServer (async (request, response) => {
|
||||||
response.setHeader ('Cache-Control', 'private, no-store')
|
response.setHeader ('Cache-Control', 'private, no-store')
|
||||||
const forwardedFor = request.headers['x-forwarded-for'] ?? request.socket.remoteAddress
|
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)
|
const address = new URL (request.url, origin)
|
||||||
{
|
const target = new URL (apiBaseURL)
|
||||||
response.writeHead (403).end ()
|
target.pathname = target.pathname.replace (/\/$/, '') + address.pathname.slice (4)
|
||||||
return
|
target.search = address.search
|
||||||
}
|
const send = target.protocol === 'https:' ? requestHTTPS : requestHTTP
|
||||||
const code = request.headers['x-transfer-code']
|
const upstream = send (target, {
|
||||||
if (typeof code !== 'string' || code.length > 512 || code.length === 0)
|
method: request.method,
|
||||||
{
|
headers: { ...request.headers, host: target.host,
|
||||||
response.writeHead (400).end ()
|
'x-forwarded-host': new URL (origin).host,
|
||||||
return
|
'x-forwarded-proto': new URL (origin).protocol.replace (':', ''),
|
||||||
}
|
'x-forwarded-for': forwardedFor } }, result => {
|
||||||
try
|
response.writeHead (result.statusCode ?? 502, result.headers)
|
||||||
{
|
result.on ('error', () => response.destroy ())
|
||||||
const verified = await fetch (new URL ('users/session', apiBaseURL.replace (/\/?$/, '/')), {
|
result.pipe (response)
|
||||||
headers: { 'X-Transfer-Code': code, 'X-Forwarded-For': forwardedFor },
|
})
|
||||||
signal: AbortSignal.timeout (10_000) })
|
upstream.setTimeout (10_000, () => upstream.destroy (new Error ('API timeout')))
|
||||||
if (!verified.ok)
|
upstream.on ('error', () => {
|
||||||
{
|
if (response.headersSent)
|
||||||
response.writeHead (verified.status === 401 || verified.status === 403
|
response.destroy ()
|
||||||
? verified.status : 503).end ()
|
else
|
||||||
return
|
response.writeHead (503).end ()
|
||||||
}
|
})
|
||||||
response.setHeader ('Set-Cookie', `${cookieName}=${encodeSession (code)}; `
|
request.on ('aborted', () => upstream.destroy ())
|
||||||
+ `Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${sessionSeconds}`)
|
request.pipe (upstream)
|
||||||
response.writeHead (204).end ()
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
response.writeHead (503).end ()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!['GET', 'HEAD'].includes (request.method))
|
if (!['GET', 'HEAD'].includes (request.method))
|
||||||
@@ -124,7 +85,9 @@ const server = createServer (async (request, response) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await render (url.pathname + url.search, origin, apiBaseURL, {
|
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)
|
if (result?.location != null)
|
||||||
{
|
{
|
||||||
response.writeHead (result.status, { Location: result.location }).end ()
|
response.writeHead (result.status, { Location: result.location }).end ()
|
||||||
|
|||||||
+48
-49
@@ -16,11 +16,14 @@ import TopNav from '@/components/TopNav'
|
|||||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
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 { prepareHelpPage } from '@/lib/help'
|
||||||
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
||||||
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
||||||
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
|
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||||
|
import { switchPostFilterLocale } from '@/lib/posts'
|
||||||
|
import { switchWikiLocale } from '@/lib/wiki'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { applyClientAnimationMode,
|
import { applyClientAnimationMode,
|
||||||
applyClientAppearance,
|
applyClientAppearance,
|
||||||
@@ -42,7 +45,7 @@ import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
|||||||
import type { RouteMatch } from 'react-router-dom'
|
import type { RouteMatch } from 'react-router-dom'
|
||||||
|
|
||||||
import type { ClientAnimationMode } from '@/lib/settings'
|
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 { PageRoute } from '@/lib/routeLocales'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
@@ -101,10 +104,12 @@ export const pageRoutes = (
|
|||||||
): PageRoute[] => [
|
): PageRoute[] => [
|
||||||
{ path: '', element: <LocaleRedirect to="/posts"/>,
|
{ path: '', element: <LocaleRedirect to="/posts"/>,
|
||||||
handle: { redirectTo: '/posts' } },
|
handle: { redirectTo: '/posts' } },
|
||||||
{ path: 'posts', element: <PostListPage/> },
|
{ path: 'posts', element: <PostListPage/>,
|
||||||
|
handle: { switchLocale: switchPostFilterLocale } },
|
||||||
{ path: 'posts/new', element: <PostNewPage user={user}/>,
|
{ path: 'posts/new', element: <PostNewPage user={user}/>,
|
||||||
handle: { robots: 'noindex' } },
|
handle: { robots: 'noindex' } },
|
||||||
{ path: 'posts/search', element: <PostSearchPage/> },
|
{ path: 'posts/search', element: <PostSearchPage/>,
|
||||||
|
handle: { switchLocale: switchPostFilterLocale } },
|
||||||
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> },
|
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> },
|
||||||
{ path: 'posts/changes', element: <PostHistoryPage/> },
|
{ path: 'posts/changes', element: <PostHistoryPage/> },
|
||||||
{ path: 'tags', element: <TagListPage/> },
|
{ path: 'tags', element: <TagListPage/> },
|
||||||
@@ -122,13 +127,17 @@ export const pageRoutes = (
|
|||||||
handle: { robots: 'noindex', authentication: 'user' } },
|
handle: { robots: 'noindex', authentication: 'user' } },
|
||||||
{ path: ':id', element: <MaterialDetailPage/> }] },
|
{ path: ':id', element: <MaterialDetailPage/> }] },
|
||||||
{ path: 'wiki', element: <WikiSearchPage/> },
|
{ path: 'wiki', element: <WikiSearchPage/> },
|
||||||
{ path: 'wiki/:title', element: <WikiDetailPage/> },
|
{ path: 'wiki/:title', element: <WikiDetailPage/>,
|
||||||
|
handle: { switchLocale: switchWikiLocale } },
|
||||||
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
|
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
|
||||||
handle: { robots: 'noindex' } },
|
handle: { robots: 'noindex' } },
|
||||||
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
|
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
|
||||||
handle: { robots: 'noindex' } },
|
handle: { robots: 'noindex',
|
||||||
{ path: 'wiki/:id/diff', element: <WikiDiffPage/> },
|
switchLocale: request => switchWikiLocale (request, 'edit') } },
|
||||||
{ path: 'wiki/changes', element: <WikiHistoryPage/> },
|
{ path: 'wiki/:id/diff', element: <WikiDiffPage/>,
|
||||||
|
handle: { switchLocale: request => switchWikiLocale (request, 'diff') } },
|
||||||
|
{ path: 'wiki/changes', element: <WikiHistoryPage/>,
|
||||||
|
handle: { switchLocale: request => switchWikiLocale (request, 'history') } },
|
||||||
{ path: 'help', element: <HelpListPage/> },
|
{ path: 'help', element: <HelpListPage/> },
|
||||||
{ path: 'help/changes', element: <HelpChangesPage/> },
|
{ path: 'help/changes', element: <HelpChangesPage/> },
|
||||||
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> },
|
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> },
|
||||||
@@ -177,7 +186,15 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
|
|||||||
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
||||||
const client = useQueryClient ()
|
const client = useQueryClient ()
|
||||||
const location = useLocation ()
|
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<string> => {
|
||||||
|
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<User | null> (
|
const [user, setUser] = useState<User | null> (
|
||||||
() => client.getQueryData<User> (sessionKeys.current) ?? null)
|
() => client.getQueryData<User> (sessionKeys.current) ?? null)
|
||||||
const [status, setStatus] = useState (200)
|
const [status, setStatus] = useState (200)
|
||||||
@@ -213,36 +230,21 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
}, [animationMode])
|
}, [animationMode])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const createUser = async () => {
|
let cancelled = false
|
||||||
const data = await apiPost<{ code: string; user: User }> ('/users')
|
void (async () => {
|
||||||
if (data.code)
|
try
|
||||||
{
|
|
||||||
localStorage.setItem ('user_code', data.code)
|
|
||||||
setUser (data.user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = localStorage.getItem ('user_code')
|
|
||||||
if (code)
|
|
||||||
{
|
{
|
||||||
void (async () => {
|
const user = await initialiseUserSession ()
|
||||||
try
|
if (!cancelled)
|
||||||
{
|
setUser (user)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
}
|
}
|
||||||
else
|
catch (error)
|
||||||
createUser ()
|
{
|
||||||
|
if (!cancelled && isApiError (error))
|
||||||
|
setStatus (error.response?.status ?? 503)
|
||||||
|
}
|
||||||
|
}) ()
|
||||||
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -295,7 +297,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
layout={animationMode === 'off' ? false : 'position'}
|
layout={animationMode === 'off' ? false : 'position'}
|
||||||
transition={{ layout: appLayoutTransition }}
|
transition={{ layout: appLayoutTransition }}
|
||||||
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||||
<TopNav user={user} locales={locales}/>
|
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
|
||||||
<RouteTransitionWrapper
|
<RouteTransitionWrapper
|
||||||
animationMode={animationMode}
|
animationMode={animationMode}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -315,13 +317,14 @@ const LocaleRoute: FC = () => {
|
|||||||
const { locale } = useParams ()
|
const { locale } = useParams ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const { i18n } = useTranslation ()
|
const { i18n } = useTranslation ()
|
||||||
const { data, isPending, isError, refetch } = useQuery ({
|
const { data, isError } = useQuery ({
|
||||||
queryKey: localesKeys.index,
|
queryKey: localesKeys.index,
|
||||||
queryFn: fetchLocales,
|
queryFn: fetchLocales,
|
||||||
staleTime: 0,
|
staleTime: 60_000,
|
||||||
refetchOnMount: 'always',
|
refetchInterval: 60_000,
|
||||||
refetchOnWindowFocus: 'always',
|
refetchOnMount: true,
|
||||||
refetchOnReconnect: 'always' })
|
refetchOnWindowFocus: true,
|
||||||
|
refetchOnReconnect: true })
|
||||||
const activeLocale =
|
const activeLocale =
|
||||||
(isLocaleCode (locale)
|
(isLocaleCode (locale)
|
||||||
&& data?.locales.some (entry => entry.code === locale))
|
&& data?.locales.some (entry => entry.code === locale))
|
||||||
@@ -335,16 +338,12 @@ const LocaleRoute: FC = () => {
|
|||||||
void i18n.changeLanguage (activeLocale)
|
void i18n.changeLanguage (activeLocale)
|
||||||
}, [activeLocale, i18n])
|
}, [activeLocale, i18n])
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
void refetch ()
|
|
||||||
}, [location.key, refetch])
|
|
||||||
|
|
||||||
const redirect = legacyLocalePath (location)
|
const redirect = legacyLocalePath (location)
|
||||||
if (!(isLocaleCode (locale)) && redirect == null)
|
if (!(isLocaleCode (locale)) && redirect == null)
|
||||||
return <NotFound/>
|
return <NotFound/>
|
||||||
if (isError)
|
if (isError && data == null)
|
||||||
return <ServiceUnavailable/>
|
return <ServiceUnavailable/>
|
||||||
if (isPending)
|
if (data == null)
|
||||||
return null
|
return null
|
||||||
if (isLocaleCode (locale))
|
if (isLocaleCode (locale))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Separator from '@/components/MenuSeparator'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import TopNavUser from '@/components/TopNavUser'
|
import TopNavUser from '@/components/TopNavUser'
|
||||||
import TranslatedText from '@/components/TranslatedText'
|
import TranslatedText from '@/components/TranslatedText'
|
||||||
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||||
import { isLocaleCode,
|
import { isLocaleCode,
|
||||||
localeFromPath,
|
localeFromPath,
|
||||||
@@ -27,10 +28,13 @@ import { fetchWikiPage } from '@/lib/wiki'
|
|||||||
|
|
||||||
import type { FC, MouseEvent } from 'react'
|
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'
|
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<string> }
|
||||||
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
||||||
|
|
||||||
|
|
||||||
@@ -125,12 +129,21 @@ export const menuOutline = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const TopNav: FC<Props> = ({ user, locales }) => {
|
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||||
const { message, label } = useUITranslation ('shared')
|
const { message, label } = useUITranslation ('shared')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const localeName = locales.find (entry => entry.code === locale)?.name
|
const localeName = locales.find (entry => entry.code === locale)?.name
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const location = useLocation ()
|
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 behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const animationsOff = animationMode === 'off'
|
const animationsOff = animationMode === 'off'
|
||||||
@@ -380,13 +393,34 @@ const TopNav: FC<Props> = ({ user, locales }) => {
|
|||||||
<select
|
<select
|
||||||
aria-label={formatUIString (localeName ?? '', locale)}
|
aria-label={formatUIString (localeName ?? '', locale)}
|
||||||
value={locale}
|
value={locale}
|
||||||
|
disabled={switchingLocale}
|
||||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
onChange={event => {
|
onChange={async event => {
|
||||||
const nextLocale = event.target.value
|
const nextLocale = event.target.value
|
||||||
if (isLocaleCode (nextLocale)
|
if (isLocaleCode (nextLocale)
|
||||||
&& locales.some (entry => entry.code === nextLocale))
|
&& locales.some (entry => entry.code === nextLocale))
|
||||||
{
|
{
|
||||||
navigate (switchLocalePath (location, nextLocale))
|
const request = ++localeSwitchRequest.current
|
||||||
|
setSwitchingLocale (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const path = resolveLocaleSwitch == null
|
||||||
|
? switchLocalePath (location, nextLocale)
|
||||||
|
: await resolveLocaleSwitch (nextLocale)
|
||||||
|
if (request === localeSwitchRequest.current
|
||||||
|
&& currentLocation.current.key === location.key)
|
||||||
|
navigate (path)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (request === localeSwitchRequest.current)
|
||||||
|
toast ({ title: label ('common:fetchError') })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (request === localeSwitchRequest.current)
|
||||||
|
setSwitchingLocale (false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{locales.map (entry => isLocaleCode (entry.code) && (
|
{locales.map (entry => isLocaleCode (entry.code) && (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import { rememberUserCode } from '@/lib/users'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
|
|||||||
'/users/verify', { code: inputCode })
|
'/users/verify', { code: inputCode })
|
||||||
if (data.valid)
|
if (data.valid)
|
||||||
{
|
{
|
||||||
localStorage.setItem ('user_code', inputCode)
|
rememberUserCode (data.user.inheritanceCode)
|
||||||
setUser (data.user)
|
setUser (data.user)
|
||||||
toast ({ title: label ('transfer.success') })
|
toast ({ title: label ('transfer.success') })
|
||||||
onVisibleChange (false)
|
onVisibleChange (false)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Dialog,
|
|||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import { rememberUserCode } from '@/lib/users'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
|
|||||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
if (data.code)
|
if (data.code)
|
||||||
{
|
{
|
||||||
localStorage.setItem ('user_code', data.code)
|
rememberUserCode (data.code)
|
||||||
setUser (user => ({ ...user, inheritanceCode: data.code } as User))
|
setUser (user => ({ ...user, inheritanceCode: data.code } as User))
|
||||||
toast ({ title: label ('userCode.renewed') })
|
toast ({ title: label ('userCode.renewed') })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// frontend/src/config.ts
|
// frontend/src/config.ts
|
||||||
const ENV: string = 'development'
|
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002' }
|
API_BASE_URL: '/api' }
|
||||||
|
|
||||||
export const API_BASE_URL = config.API_BASE_URL
|
export const API_BASE_URL = config.API_BASE_URL
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ type RenderedPage = {
|
|||||||
html: string
|
html: string
|
||||||
head: string
|
head: string
|
||||||
locale: string
|
locale: string
|
||||||
bootstrap: string }
|
bootstrap: string
|
||||||
|
setCookies?: string[] }
|
||||||
|
|
||||||
type RouteResponse = { status: 404 | 308; location?: string }
|
type RouteResponse = { status: 404 | 308; location?: string }
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ export const render = async (
|
|||||||
address: string,
|
address: string,
|
||||||
origin: string,
|
origin: string,
|
||||||
apiBaseURL: string,
|
apiBaseURL: string,
|
||||||
authentication: Pick<RenderContext, 'transferCode' | 'forwardedFor'> = { },
|
authentication: Pick<RenderContext, 'cookie' | 'forwardedFor'> = { },
|
||||||
): Promise<RenderedPage | RouteResponse> => {
|
): Promise<RenderedPage | RouteResponse> => {
|
||||||
const url = new URL (address, origin)
|
const url = new URL (address, origin)
|
||||||
const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' })
|
const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' })
|
||||||
@@ -73,7 +74,8 @@ export const render = async (
|
|||||||
if (known?.route.handle?.redirectTo != null)
|
if (known?.route.handle?.redirectTo != null)
|
||||||
return { status: 308,
|
return { status: 308,
|
||||||
location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
|
location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
|
||||||
const requestContext = { pathname: url.pathname, origin, apiBaseURL,
|
const requestContext: RenderContext & { status: number } = {
|
||||||
|
pathname: url.pathname, origin, apiBaseURL,
|
||||||
...authentication, status: 200 }
|
...authentication, status: 200 }
|
||||||
return withRenderContext (requestContext, async () => {
|
return withRenderContext (requestContext, async () => {
|
||||||
const client = new QueryClient ({ defaultOptions: {
|
const client = new QueryClient ({ defaultOptions: {
|
||||||
@@ -83,7 +85,7 @@ export const render = async (
|
|||||||
const locales = await client.fetchQuery ({
|
const locales = await client.fetchQuery ({
|
||||||
queryKey: localesKeys.index, queryFn: fetchLocales })
|
queryKey: localesKeys.index, queryFn: fetchLocales })
|
||||||
const active = locales.locales.some (entry => entry.code === locale)
|
const active = locales.locales.some (entry => entry.code === locale)
|
||||||
if (authentication.transferCode != null)
|
if (authentication.cookie != null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -95,7 +97,7 @@ export const render = async (
|
|||||||
{
|
{
|
||||||
if (!isApiError (error) || error.response?.status !== 401)
|
if (!isApiError (error) || error.response?.status !== 401)
|
||||||
throw error
|
throw error
|
||||||
delete requestContext.transferCode
|
delete requestContext.cookie
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const prepare = known?.route.handle?.prepare
|
const prepare = known?.route.handle?.prepare
|
||||||
@@ -177,7 +179,8 @@ export const render = async (
|
|||||||
url: url.pathname + url.search })
|
url: url.pathname + url.search })
|
||||||
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
|
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
|
||||||
.replace (/\u2029/g, '\\u2029')
|
.replace (/\u2029/g, '\\u2029')
|
||||||
return { status, html, head, locale, bootstrap }
|
return { status, html, head, locale, bootstrap,
|
||||||
|
setCookies: requestContext.setCookies }
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,11 +28,17 @@ vi.mock ('@/config', () => ({
|
|||||||
|
|
||||||
describe ('api helpers', () => {
|
describe ('api helpers', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
|
vi.resetModules ()
|
||||||
vi.clearAllMocks ()
|
vi.clearAllMocks ()
|
||||||
localStorage.clear ()
|
localStorage.clear ()
|
||||||
|
mocks.client.get.mockReset ().mockResolvedValue ({ data: { token: 'csrf-token' } })
|
||||||
|
mocks.client.post.mockReset ().mockResolvedValue ({ data: { ok: true } })
|
||||||
|
mocks.client.patch.mockReset ().mockResolvedValue ({ data: { ok: true } })
|
||||||
|
mocks.client.delete.mockReset ().mockResolvedValue ({ data: '' })
|
||||||
|
mocks.isAxiosError.mockReturnValue (false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('adds the transfer code header and camelizes get responses', async () => {
|
it ('uses Cookies without sending the recovery code and camelises responses', async () => {
|
||||||
localStorage.setItem ('user_code', 'abc123')
|
localStorage.setItem ('user_code', 'abc123')
|
||||||
mocks.client.get.mockResolvedValueOnce ({
|
mocks.client.get.mockResolvedValueOnce ({
|
||||||
data: { post_id: 1, nested_value: { created_at: 'now' } },
|
data: { post_id: 1, nested_value: { created_at: 'now' } },
|
||||||
@@ -47,11 +53,13 @@ describe ('api helpers', () => {
|
|||||||
expect (mocks.client.get).toHaveBeenCalledWith (
|
expect (mocks.client.get).toHaveBeenCalledWith (
|
||||||
'/posts/1',
|
'/posts/1',
|
||||||
{
|
{
|
||||||
headers: { 'X-Transfer-Code': 'abc123', 'X-Extra': '1' },
|
headers: { 'X-Extra': '1' },
|
||||||
params: { page: 2 },
|
params: { page: 2 },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
expect (data).toEqual ({ postId: 1, nestedValue: { createdAt: 'now' } })
|
expect (data).toEqual ({ postId: 1, nestedValue: { createdAt: 'now' } })
|
||||||
|
const { default: axios } = await import ('axios')
|
||||||
|
expect (axios.create).toHaveBeenCalledWith ({ baseURL: '/api', withCredentials: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('passes an empty body for post-like requests when body is omitted', async () => {
|
it ('passes an empty body for post-like requests when body is omitted', async () => {
|
||||||
@@ -63,7 +71,7 @@ describe ('api helpers', () => {
|
|||||||
expect (mocks.client.patch).toHaveBeenCalledWith (
|
expect (mocks.client.patch).toHaveBeenCalledWith (
|
||||||
'/posts/1',
|
'/posts/1',
|
||||||
{},
|
{},
|
||||||
{ headers: { 'X-Transfer-Code': '' } },
|
{ headers: { 'X-CSRF-Token': 'csrf-token' } },
|
||||||
)
|
)
|
||||||
expect (data.okValue).toBe (true)
|
expect (data.okValue).toBe (true)
|
||||||
})
|
})
|
||||||
@@ -88,9 +96,70 @@ describe ('api helpers', () => {
|
|||||||
|
|
||||||
expect (mocks.client.delete).toHaveBeenCalledWith (
|
expect (mocks.client.delete).toHaveBeenCalledWith (
|
||||||
'/posts/1',
|
'/posts/1',
|
||||||
{ headers: { 'X-Transfer-Code': '' } },
|
{ headers: { 'X-CSRF-Token': 'csrf-token' } },
|
||||||
)
|
)
|
||||||
expect (isApiError (err)).toBe (true)
|
expect (isApiError (err)).toBe (true)
|
||||||
expect (mocks.isAxiosError).toHaveBeenCalledWith (err)
|
expect (mocks.isAxiosError).toHaveBeenCalledWith (err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('shares initial CSRF acquisition across concurrent writes and reuses the token', async () => {
|
||||||
|
let complete!: (value: { data: { token: string } }) => void
|
||||||
|
mocks.client.get.mockReturnValueOnce (new Promise (resolve => { complete = resolve }))
|
||||||
|
const { apiPost, apiPatch } = await import ('@/lib/api')
|
||||||
|
const first = apiPost ('/posts', { title: 'one' })
|
||||||
|
const second = apiPatch ('/posts/1', { title: 'two' })
|
||||||
|
expect (mocks.client.get).toHaveBeenCalledTimes (1)
|
||||||
|
expect (mocks.client.post).not.toHaveBeenCalled ()
|
||||||
|
complete ({ data: { token: 'shared-token' } })
|
||||||
|
await Promise.all ([first, second])
|
||||||
|
await apiPatch ('/posts/1')
|
||||||
|
expect (mocks.client.get).toHaveBeenCalledTimes (1)
|
||||||
|
expect (mocks.client.post).toHaveBeenCalledWith ('/posts', { title: 'one' }, {
|
||||||
|
headers: { 'X-CSRF-Token': 'shared-token' } })
|
||||||
|
expect (mocks.client.patch).toHaveBeenLastCalledWith ('/posts/1', { }, {
|
||||||
|
headers: { 'X-CSRF-Token': 'shared-token' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each (['/users', '/users/verify', '/users/code/renew']) (
|
||||||
|
'refreshes CSRF after a session change through %s', async path => {
|
||||||
|
mocks.client.get.mockResolvedValueOnce ({ data: { token: 'before' } })
|
||||||
|
mocks.client.get.mockResolvedValueOnce ({ data: { token: 'after' } })
|
||||||
|
mocks.client.post.mockResolvedValueOnce ({ data: { valid: true } })
|
||||||
|
const { apiPost, apiPatch } = await import ('@/lib/api')
|
||||||
|
await apiPost (path)
|
||||||
|
await apiPatch ('/posts/1')
|
||||||
|
expect (mocks.client.get).toHaveBeenCalledTimes (2)
|
||||||
|
expect (mocks.client.patch).toHaveBeenCalledWith ('/posts/1', { }, {
|
||||||
|
headers: { 'X-CSRF-Token': 'after' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('clears the recovery code and CSRF token after session deletion', async () => {
|
||||||
|
localStorage.setItem ('user_code', 'backup')
|
||||||
|
const { apiDelete, apiPatch } = await import ('@/lib/api')
|
||||||
|
await apiDelete ('/users/session')
|
||||||
|
expect (localStorage.getItem ('user_code')).toBeNull ()
|
||||||
|
await apiPatch ('/posts/1')
|
||||||
|
expect (mocks.client.get).toHaveBeenCalledTimes (2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('clears rejected CSRF tokens without replaying a write', async () => {
|
||||||
|
const error = { response: { status: 403, headers: { 'x-csrf-invalid': '1' } } }
|
||||||
|
mocks.isAxiosError.mockReturnValue (true)
|
||||||
|
mocks.client.patch.mockRejectedValueOnce (error)
|
||||||
|
const { apiPatch } = await import ('@/lib/api')
|
||||||
|
await expect (apiPatch ('/posts/1')).rejects.toBe (error)
|
||||||
|
expect (mocks.client.patch).toHaveBeenCalledTimes (1)
|
||||||
|
await apiPatch ('/posts/1')
|
||||||
|
expect (mocks.client.get).toHaveBeenCalledTimes (2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('allows CSRF acquisition to recover after a failed request', async () => {
|
||||||
|
const error = new Error ('offline')
|
||||||
|
mocks.client.get.mockRejectedValueOnce (error)
|
||||||
|
const { apiPatch } = await import ('@/lib/api')
|
||||||
|
await expect (apiPatch ('/posts/1')).rejects.toBe (error)
|
||||||
|
expect (mocks.client.patch).not.toHaveBeenCalled ()
|
||||||
|
await apiPatch ('/posts/1')
|
||||||
|
expect (mocks.client.get).toHaveBeenCalledTimes (2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+122
-27
@@ -4,9 +4,10 @@ import toCamel from 'camelcase-keys'
|
|||||||
import { API_BASE_URL } from '@/config'
|
import { API_BASE_URL } from '@/config'
|
||||||
import { currentRenderContext } from '@/lib/renderContext'
|
import { currentRenderContext } from '@/lib/renderContext'
|
||||||
|
|
||||||
import type { AxiosError, AxiosRequestConfig } from 'axios'
|
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
import type { RenderContext } from '@/lib/renderContext'
|
||||||
|
|
||||||
type Opt = {
|
type Opt = {
|
||||||
locale?: LocaleCode
|
locale?: LocaleCode
|
||||||
@@ -15,49 +16,128 @@ type Opt = {
|
|||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
responseType?: 'blob' }
|
responseType?: 'blob' }
|
||||||
|
|
||||||
const client = axios.create ({ baseURL: API_BASE_URL })
|
const client = axios.create ({ baseURL: API_BASE_URL, withCredentials: true })
|
||||||
|
type CSRFState = NonNullable<RenderContext['csrf']>
|
||||||
|
let browserCSRF: CSRFState = { }
|
||||||
|
|
||||||
|
|
||||||
const withUserCode = (opt?: Opt): AxiosRequestConfig => {
|
const csrfState = (): CSRFState => {
|
||||||
|
if (typeof window !== 'undefined')
|
||||||
|
return browserCSRF
|
||||||
|
const context = currentRenderContext ()
|
||||||
|
if (context == null)
|
||||||
|
throw new Error ('Missing request context for CSRF')
|
||||||
|
return context.csrf ??= { }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const resetCSRF = (): void => {
|
||||||
|
if (typeof window !== 'undefined')
|
||||||
|
browserCSRF = { }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const context = currentRenderContext ()
|
||||||
|
if (context != null)
|
||||||
|
context.csrf = { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const withSession = (opt?: Opt): AxiosRequestConfig => {
|
||||||
const { locale, ...config } = opt ?? { }
|
const { locale, ...config } = opt ?? { }
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
...(currentRenderContext ()?.apiBaseURL != null
|
...(currentRenderContext ()?.apiBaseURL != null
|
||||||
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
|
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
|
||||||
...(locale != null && { params: { locale, ...config.params } }),
|
...(locale != null && { params: { locale, ...config.params } }),
|
||||||
headers: { 'X-Transfer-Code': typeof window === 'undefined'
|
headers: { ...(currentRenderContext ()?.cookie != null && {
|
||||||
? currentRenderContext ()?.transferCode ?? ''
|
Cookie: currentRenderContext ()!.cookie }),
|
||||||
: localStorage.getItem ('user_code') ?? '',
|
...(typeof window === 'undefined' && currentRenderContext () != null && {
|
||||||
|
'X-Forwarded-Host': new URL (currentRenderContext ()!.origin).host,
|
||||||
|
'X-Forwarded-Proto': new URL (currentRenderContext ()!.origin)
|
||||||
|
.protocol.replace (':', '') }),
|
||||||
...(currentRenderContext ()?.forwardedFor != null && {
|
...(currentRenderContext ()?.forwardedFor != null && {
|
||||||
'X-Forwarded-For': currentRenderContext ()!.forwardedFor }),
|
'X-Forwarded-For': currentRenderContext ()!.forwardedFor }),
|
||||||
...(config.headers ?? { }) } }
|
...(config.headers ?? { }) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
|
||||||
|
const token = await csrfToken ()
|
||||||
|
const config = withSession (opt)
|
||||||
|
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const csrfToken = async (): Promise<string> => {
|
||||||
|
const state = csrfState ()
|
||||||
|
if (state.token != null)
|
||||||
|
return state.token
|
||||||
|
state.pending ??= client.get<{ token: string }> ('/users/csrf', withSession ())
|
||||||
|
.then (response => {
|
||||||
|
if (csrfState () !== state)
|
||||||
|
return csrfToken ()
|
||||||
|
if (typeof response.data.token !== 'string')
|
||||||
|
throw new Error ('Missing CSRF token')
|
||||||
|
rememberSessionCookies (response.headers)
|
||||||
|
state.token = response.data.token
|
||||||
|
delete state.pending
|
||||||
|
return state.token
|
||||||
|
}).catch (error => {
|
||||||
|
delete state.pending
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
return state.pending
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const csrfWrite = async <T,> (
|
||||||
|
send: (config: AxiosRequestConfig) => Promise<AxiosResponse<T>>,
|
||||||
|
opt?: Opt,
|
||||||
|
): Promise<AxiosResponse<T>> => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await send (await withCSRF (opt))
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
if (axios.isAxiosError (error)
|
||||||
|
&& error.response?.headers?.['x-csrf-invalid'] === '1')
|
||||||
|
resetCSRF ()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const rememberSessionCookies = (headers: AxiosResponse['headers']) => {
|
||||||
|
const context = currentRenderContext ()
|
||||||
|
const cookies = headers?.['set-cookie']
|
||||||
|
if (typeof window === 'undefined' && context != null && Array.isArray (cookies))
|
||||||
|
{
|
||||||
|
context.setCookies = cookies
|
||||||
|
const pairs = new Map ((context.cookie ?? '').split (';').filter (Boolean)
|
||||||
|
.map (value => value.trim ().split (/=(.*)/s).slice (0, 2) as [string, string]))
|
||||||
|
for (const cookie of cookies)
|
||||||
|
{
|
||||||
|
const pair = cookie.split (';')[0]
|
||||||
|
const separator = pair.indexOf ('=')
|
||||||
|
pairs.set (pair.slice (0, separator), pair.slice (separator + 1))
|
||||||
|
}
|
||||||
|
context.cookie = [...pairs].map (([key, value]) => `${key}=${value}`).join ('; ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const apiP = async <T> (
|
const apiP = async <T> (
|
||||||
method: 'post' | 'put' | 'patch',
|
method: 'post' | 'put' | 'patch',
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
opt?: Opt): Promise<T> => {
|
opt?: Opt): Promise<T> => {
|
||||||
const res = await client[method] (path, body ?? { }, withUserCode (opt))
|
const res = await csrfWrite (config => client[method] (path, body ?? { }, config), opt)
|
||||||
if (typeof window !== 'undefined' && method === 'post'
|
if (method === 'post' && (path === '/users' || path === '/users/code/renew'
|
||||||
&& ['/users', '/users/verify', '/users/code/renew'].includes (path))
|
|| (path === '/users/verify' && res.data?.valid)))
|
||||||
{
|
resetCSRF ()
|
||||||
const code = res.data?.code ?? (res.data?.valid ? (body as { code?: string })?.code : null)
|
rememberSessionCookies (res.headers)
|
||||||
if (typeof code === 'string')
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await fetch ('/_ssr/session', {
|
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: { 'X-Transfer-Code': code } })
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Existing CSR authentication remains usable if the SSR endpoint is unavailable.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (opt?.responseType === 'blob')
|
if (opt?.responseType === 'blob')
|
||||||
return res.data as T
|
return res.data as T
|
||||||
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
||||||
@@ -67,7 +147,8 @@ const apiP = async <T> (
|
|||||||
export const apiGet = async <T> (
|
export const apiGet = async <T> (
|
||||||
path: string,
|
path: string,
|
||||||
opt?: Opt): Promise<T> => {
|
opt?: Opt): Promise<T> => {
|
||||||
const res = await client.get (path, withUserCode (opt))
|
const res = await client.get (path, withSession (opt))
|
||||||
|
rememberSessionCookies (res.headers)
|
||||||
if (opt?.responseType === 'blob')
|
if (opt?.responseType === 'blob')
|
||||||
return res.data as T
|
return res.data as T
|
||||||
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
||||||
@@ -95,7 +176,21 @@ export const apiPatch = async <T> (
|
|||||||
export const apiDelete = async <T = void> (
|
export const apiDelete = async <T = void> (
|
||||||
path: string,
|
path: string,
|
||||||
opt?: Opt): Promise<T> => {
|
opt?: Opt): Promise<T> => {
|
||||||
const res = await client.delete (path, withUserCode (opt))
|
const res = await csrfWrite (config => client.delete (path, config), opt)
|
||||||
|
if (path === '/users/session')
|
||||||
|
resetCSRF ()
|
||||||
|
rememberSessionCookies (res.headers)
|
||||||
|
if (typeof window !== 'undefined' && path === '/users/session')
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
localStorage.removeItem ('user_code')
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The server session is already cleared even if storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
if (res.data == null || res.data === '')
|
if (res.data == null || res.data === '')
|
||||||
return undefined as T
|
return undefined as T
|
||||||
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
||||||
|
|||||||
+31
-1
@@ -1,11 +1,13 @@
|
|||||||
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
|
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
|
||||||
|
import { switchLocalePath } from '@/lib/locales'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
import type { LocaleSwitchRequest } from '@/lib/routeLocales'
|
||||||
import type { FetchPostsParams, Post, PostVersion } from '@/types'
|
import type { FetchPostsParams, Post, PostVersion } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
export const fetchPosts = async (
|
export const fetchPosts = async (
|
||||||
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
|
{ url, title, tags, tagReferences, match, createdFrom, createdTo, updatedFrom, updatedTo,
|
||||||
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
|
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -15,6 +17,7 @@ export const fetchPosts = async (
|
|||||||
...(url && { url }),
|
...(url && { url }),
|
||||||
...(title && { title }),
|
...(title && { title }),
|
||||||
...(tags && { tags }),
|
...(tags && { tags }),
|
||||||
|
...(tagReferences && { tag_refs: tagReferences }),
|
||||||
...(match && { match }),
|
...(match && { match }),
|
||||||
...(createdFrom && { created_from: createdFrom }),
|
...(createdFrom && { created_from: createdFrom }),
|
||||||
...(createdTo && { created_to: createdTo }),
|
...(createdTo && { created_to: createdTo }),
|
||||||
@@ -27,6 +30,33 @@ export const fetchPosts = async (
|
|||||||
...(order && { order }) } })
|
...(order && { order }) } })
|
||||||
|
|
||||||
|
|
||||||
|
export const switchPostFilterLocale = async (
|
||||||
|
{ location, locale, targetLocale }: LocaleSwitchRequest,
|
||||||
|
): Promise<string> => {
|
||||||
|
const query = new URLSearchParams (location.search)
|
||||||
|
const tags = query.get ('tags')
|
||||||
|
if (tags == null || tags.trim () === '')
|
||||||
|
return switchLocalePath (location, targetLocale)
|
||||||
|
|
||||||
|
const { references } = await apiGet<{ references: {
|
||||||
|
name: string
|
||||||
|
tagId?: number
|
||||||
|
externalTagId?: number
|
||||||
|
negative?: boolean
|
||||||
|
missing?: boolean }[] }> ('/posts/tag_filters', {
|
||||||
|
locale, params: { tags, target_locale: targetLocale,
|
||||||
|
tag_refs: query.get ('tag_refs') ?? undefined } })
|
||||||
|
query.set ('tags', references.map (reference => reference.name).join (' '))
|
||||||
|
query.set ('tag_refs', JSON.stringify (references.map (reference => ({
|
||||||
|
name: reference.name,
|
||||||
|
negative: reference.negative,
|
||||||
|
...(reference.tagId != null && { tag_id: reference.tagId }),
|
||||||
|
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
|
||||||
|
...(reference.missing && { missing: true }) }))))
|
||||||
|
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchPost = async (
|
export const fetchPost = async (
|
||||||
id: string,
|
id: string,
|
||||||
locale: LocaleCode = 'ja',
|
locale: LocaleCode = 'ja',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const wikiApi = vi.hoisted (() => ({
|
|||||||
fetchWikiPages: vi.fn (),
|
fetchWikiPages: vi.fn (),
|
||||||
fetchWikiPageByReference: vi.fn (),
|
fetchWikiPageByReference: vi.fn (),
|
||||||
fetchWikiAutolinkCandidates: vi.fn (),
|
fetchWikiAutolinkCandidates: vi.fn (),
|
||||||
|
fetchWikiTag: vi.fn (),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock ('@/lib/posts', () => postsApi)
|
vi.mock ('@/lib/posts', () => postsApi)
|
||||||
@@ -49,6 +50,7 @@ describe ('prefetchForURL', () => {
|
|||||||
wikiApi.fetchWikiPageByTitle.mockResolvedValue (null)
|
wikiApi.fetchWikiPageByTitle.mockResolvedValue (null)
|
||||||
wikiApi.fetchWikiPageByReference.mockResolvedValue (null)
|
wikiApi.fetchWikiPageByReference.mockResolvedValue (null)
|
||||||
wikiApi.fetchWikiAutolinkCandidates.mockResolvedValue ([])
|
wikiApi.fetchWikiAutolinkCandidates.mockResolvedValue ([])
|
||||||
|
wikiApi.fetchWikiTag.mockResolvedValue (null)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('prefetches post indexes from query parameters', async () => {
|
it ('prefetches post indexes from query parameters', async () => {
|
||||||
@@ -129,7 +131,7 @@ describe ('prefetchForURL', () => {
|
|||||||
expect (wikiApi.fetchWikiPageByReference).toHaveBeenCalledWith (
|
expect (wikiApi.fetchWikiPageByReference).toHaveBeenCalledWith (
|
||||||
'Alias', { version: undefined }, 'ja')
|
'Alias', { version: undefined }, 'ja')
|
||||||
expect (wikiApi.fetchWikiPage).not.toHaveBeenCalled ()
|
expect (wikiApi.fetchWikiPage).not.toHaveBeenCalled ()
|
||||||
expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual', 'ja')
|
expect (wikiApi.fetchWikiTag).toHaveBeenCalledWith ({ name: 'Actual' }, 'ja')
|
||||||
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
|
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
|
||||||
expect.objectContaining ({ tags: 'Actual', limit: 8 }), 'ja',
|
expect.objectContaining ({ tags: 'Actual', limit: 8 }), 'ja',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import { getHelpArticle,
|
|||||||
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
|
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
|
||||||
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
|
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
|
||||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
||||||
import { fetchWikiAutolinkCandidates,
|
import { fetchWikiAutolinkCandidates,
|
||||||
fetchWikiPageByReference,
|
fetchWikiPageByReference,
|
||||||
|
fetchWikiTag,
|
||||||
fetchWikiPages } from '@/lib/wiki'
|
fetchWikiPages } from '@/lib/wiki'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
|
import type { Category, FetchPostsOrder, FetchTagsOrder, WikiTagReference } from '@/types'
|
||||||
|
|
||||||
type Prefetcher = (qc: QueryClient, url: URL, locale: LocaleCode) => Promise<void>
|
type Prefetcher = (qc: QueryClient, url: URL, locale: LocaleCode) => Promise<void>
|
||||||
|
|
||||||
@@ -127,16 +128,18 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveTitle = wikiPage?.title ?? title
|
const effectiveTitle = wikiPage?.title ?? title
|
||||||
|
const tagReference: WikiTagReference = (wikiPage != null || !/^\d+$/.test (title))
|
||||||
|
? { name: effectiveTitle } : { wikiId: title }
|
||||||
|
|
||||||
await qc.prefetchQuery ({
|
const tag = await qc.fetchQuery ({
|
||||||
queryKey: tagsKeys.show (effectiveTitle, locale),
|
queryKey: wikiKeys.tag (tagReference, locale),
|
||||||
queryFn: () => fetchTagByName (effectiveTitle, locale) })
|
queryFn: () => fetchWikiTag (tagReference, locale) })
|
||||||
|
|
||||||
if (version)
|
if (version)
|
||||||
return
|
return
|
||||||
|
|
||||||
const p = {
|
const p = {
|
||||||
tags: effectiveTitle,
|
tags: wikiPage?.title ?? tag?.name ?? title,
|
||||||
match: 'all',
|
match: 'all',
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 8,
|
limit: 8,
|
||||||
@@ -172,6 +175,8 @@ const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
|
|||||||
|
|
||||||
const keys = {
|
const keys = {
|
||||||
tags, match: m, page, limit, url: qURL, title,
|
tags, match: m, page, limit, url: qURL, title,
|
||||||
|
...(url.searchParams.get ('tag_refs') && {
|
||||||
|
tagReferences: url.searchParams.get ('tag_refs')! }),
|
||||||
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
|
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
|
||||||
updatedFrom, updatedTo, order }
|
updatedFrom, updatedTo, order }
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
FetchPostsParams,
|
FetchPostsParams,
|
||||||
FetchTagsParams,
|
FetchTagsParams,
|
||||||
MaterialFilter,
|
MaterialFilter,
|
||||||
} from '@/types'
|
WikiTagReference } from '@/types'
|
||||||
|
|
||||||
export const localesKeys = {
|
export const localesKeys = {
|
||||||
root: ['locales'] as const,
|
root: ['locales'] as const,
|
||||||
@@ -123,6 +123,8 @@ export const materialsKeys = {
|
|||||||
|
|
||||||
export const wikiKeys = {
|
export const wikiKeys = {
|
||||||
root: ['wiki'] as const,
|
root: ['wiki'] as const,
|
||||||
|
tag: (reference: WikiTagReference, locale: LocaleCode) =>
|
||||||
|
['wiki', locale, 'tag', reference] as const,
|
||||||
diff: (id: string, p: { from: string | null; to: string | null }, locale: LocaleCode) =>
|
diff: (id: string, p: { from: string | null; to: string | null }, locale: LocaleCode) =>
|
||||||
['wiki', locale, 'diff', id, p] as const,
|
['wiki', locale, 'diff', id, p] as const,
|
||||||
autolinkCandidates: (language: string, locale: LocaleCode) =>
|
autolinkCandidates: (language: string, locale: LocaleCode) =>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ export type RenderContext = {
|
|||||||
pathname: string
|
pathname: string
|
||||||
origin: string
|
origin: string
|
||||||
apiBaseURL?: string
|
apiBaseURL?: string
|
||||||
transferCode?: string
|
cookie?: string
|
||||||
|
setCookies?: string[]
|
||||||
|
csrf?: { token?: string; pending?: Promise<string> }
|
||||||
forwardedFor?: string
|
forwardedFor?: string
|
||||||
status?: number }
|
status?: number }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import type { IndexRouteObject, NonIndexRouteObject, Params } from 'react-router-dom'
|
import type { IndexRouteObject, Location, NonIndexRouteObject, Params } from 'react-router-dom'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
@@ -9,8 +9,15 @@ export type PagePreparation = {
|
|||||||
locale: LocaleCode
|
locale: LocaleCode
|
||||||
searchParams: URLSearchParams }
|
searchParams: URLSearchParams }
|
||||||
|
|
||||||
|
export type LocaleSwitchRequest = {
|
||||||
|
location: Pick<Location, 'pathname' | 'search' | 'hash'>
|
||||||
|
params: Readonly<Params>
|
||||||
|
locale: LocaleCode
|
||||||
|
targetLocale: LocaleCode }
|
||||||
|
|
||||||
export type PageRouteHandle = {
|
export type PageRouteHandle = {
|
||||||
prepare?: (request: PagePreparation) => Promise<number>
|
prepare?: (request: PagePreparation) => Promise<number>
|
||||||
|
switchLocale?: (request: LocaleSwitchRequest) => Promise<string>
|
||||||
locale?: LocaleCode
|
locale?: LocaleCode
|
||||||
robots?: 'noindex'
|
robots?: 'noindex'
|
||||||
authentication?: 'user'
|
authentication?: 'user'
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const api = vi.hoisted (() => ({ apiGet: vi.fn (), apiPost: vi.fn () }))
|
||||||
|
|
||||||
|
vi.mock ('@/lib/api', () => ({ ...api,
|
||||||
|
isApiError: (error: { isAxiosError?: boolean }) => error.isAxiosError === true }))
|
||||||
|
|
||||||
|
const user = { id: 1, name: 'Cookie user', role: 'guest', inheritanceCode: 'current-code' }
|
||||||
|
const noSession = { isAxiosError: true, response: { status: 401 } }
|
||||||
|
|
||||||
|
|
||||||
|
describe ('Cookie session initialisation', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
vi.resetModules ()
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
api.apiGet.mockReset ()
|
||||||
|
api.apiPost.mockReset ()
|
||||||
|
localStorage.clear ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps Cookie identity and updates an older recovery code', async () => {
|
||||||
|
localStorage.setItem ('user_code', 'old-code')
|
||||||
|
api.apiGet.mockResolvedValueOnce (user)
|
||||||
|
const { initialiseUserSession } = await import ('@/lib/users')
|
||||||
|
await expect (initialiseUserSession ()).resolves.toEqual (user)
|
||||||
|
expect (api.apiGet).toHaveBeenCalledWith ('/users/me')
|
||||||
|
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||||
|
expect (localStorage.getItem ('user_code')).toBe ('current-code')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('restores from storage only after Cookie authentication returns 401', async () => {
|
||||||
|
localStorage.setItem ('user_code', 'backup-code')
|
||||||
|
api.apiGet.mockRejectedValueOnce (noSession)
|
||||||
|
api.apiPost.mockResolvedValueOnce ({ valid: true, user })
|
||||||
|
const { initialiseUserSession } = await import ('@/lib/users')
|
||||||
|
await expect (initialiseUserSession ()).resolves.toEqual (user)
|
||||||
|
expect (api.apiPost).toHaveBeenCalledExactlyOnceWith (
|
||||||
|
'/users/verify', { code: 'backup-code', restore: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('creates an account after a recovery code is rejected', async () => {
|
||||||
|
localStorage.setItem ('user_code', 'expired-code')
|
||||||
|
api.apiGet.mockRejectedValueOnce (noSession)
|
||||||
|
api.apiPost.mockResolvedValueOnce ({ valid: false })
|
||||||
|
api.apiPost.mockResolvedValueOnce ({ code: user.inheritanceCode, user })
|
||||||
|
const { initialiseUserSession } = await import ('@/lib/users')
|
||||||
|
await expect (initialiseUserSession ()).resolves.toEqual (user)
|
||||||
|
expect (api.apiPost).toHaveBeenLastCalledWith ('/users')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each ([403, 503]) ('does not replace a session when the API returns %s', async status => {
|
||||||
|
localStorage.setItem ('user_code', 'backup-code')
|
||||||
|
const error = { isAxiosError: true, response: { status } }
|
||||||
|
api.apiGet.mockRejectedValueOnce (error)
|
||||||
|
const { initialiseUserSession } = await import ('@/lib/users')
|
||||||
|
await expect (initialiseUserSession ()).rejects.toBe (error)
|
||||||
|
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||||
|
expect (localStorage.getItem ('user_code')).toBe ('backup-code')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('shares concurrent initialisation to avoid duplicate account creation', async () => {
|
||||||
|
api.apiGet.mockRejectedValueOnce (noSession)
|
||||||
|
api.apiPost.mockResolvedValueOnce ({ code: user.inheritanceCode, user })
|
||||||
|
const { initialiseUserSession } = await import ('@/lib/users')
|
||||||
|
await Promise.all ([initialiseUserSession (), initialiseUserSession ()])
|
||||||
|
expect (api.apiGet).toHaveBeenCalledTimes (1)
|
||||||
|
expect (api.apiPost).toHaveBeenCalledExactlyOnceWith ('/users')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,70 @@
|
|||||||
|
import { apiGet, apiPost, isApiError } from '@/lib/api'
|
||||||
|
|
||||||
import type { User, UserRole } from '@/types'
|
import type { User, UserRole } from '@/types'
|
||||||
|
|
||||||
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
|
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
|
||||||
|
let pendingSession: Promise<User> | null = null
|
||||||
|
|
||||||
|
|
||||||
|
const storedUserCode = (): string | null => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return localStorage.getItem ('user_code')
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const rememberUserCode = (code: string): void => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
localStorage.setItem ('user_code', code)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// A missing recovery copy must not invalidate the authenticated Cookie.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const restoreUserSession = async (): Promise<User> => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await apiGet<User> ('/users/me')
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
if (!isApiError (error) || error.response?.status !== 401)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = storedUserCode ()
|
||||||
|
if (code)
|
||||||
|
{
|
||||||
|
const data = await apiPost<{ valid: boolean; user: User }> (
|
||||||
|
'/users/verify', { code, restore: true })
|
||||||
|
if (data.valid)
|
||||||
|
return data.user
|
||||||
|
}
|
||||||
|
const data = await apiPost<{ code: string; user: User }> ('/users')
|
||||||
|
return data.user
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const initialiseUserSession = (): Promise<User> => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
throw new Error ('Browser session initialisation is client-only')
|
||||||
|
pendingSession ??= restoreUserSession ().then (user => {
|
||||||
|
rememberUserCode (user.inheritanceCode)
|
||||||
|
return user
|
||||||
|
}).finally (() => {
|
||||||
|
pendingSession = null
|
||||||
|
})
|
||||||
|
return pendingSession
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const canEditContent = (
|
export const canEditContent = (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki'
|
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages,
|
||||||
|
switchWikiLocale } from '@/lib/wiki'
|
||||||
|
|
||||||
const api = vi.hoisted (() => ({ apiGet: vi.fn () }))
|
const api = vi.hoisted (() => ({ apiGet: vi.fn () }))
|
||||||
|
|
||||||
@@ -8,7 +9,60 @@ vi.mock ('@/lib/api', () => api)
|
|||||||
|
|
||||||
describe ('wiki API functions', () => {
|
describe ('wiki API functions', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
vi.clearAllMocks ()
|
api.apiGet.mockReset ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each (['detail', 'edit', 'diff', 'history'] as const) (
|
||||||
|
'switches only the Locale for a missing resource on the %s screen',
|
||||||
|
async view => {
|
||||||
|
api.apiGet.mockRejectedValue ({
|
||||||
|
isAxiosError: true, response: { status: 404 } })
|
||||||
|
const pathname = view === 'history' ? '/wiki/changes'
|
||||||
|
: `/wiki/${encodeURIComponent ('げいかうい')}${
|
||||||
|
view === 'detail' ? '' : `/${view}`}`
|
||||||
|
const search = '?id=missing&from=11&to=12&version=3&page=2'
|
||||||
|
const params = view === 'history' ? { }
|
||||||
|
: (view === 'detail' ? { title: 'げいかうい' } : { id: 'げいかうい' })
|
||||||
|
const path = await switchWikiLocale ({
|
||||||
|
location: { pathname: `/ain-Kana${pathname}`, search, hash: '#section' },
|
||||||
|
params,
|
||||||
|
locale: 'ain-Kana', targetLocale: 'ja' }, view)
|
||||||
|
expect (path).toBe (`/ja${pathname}${search}#section`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each (['wiki', 'tag'] as const) (
|
||||||
|
'rejects communication failures during the %s lookup', async lookup => {
|
||||||
|
const error = new Error ('network failure')
|
||||||
|
if (lookup === 'tag')
|
||||||
|
api.apiGet.mockRejectedValueOnce ({
|
||||||
|
isAxiosError: true, response: { status: 404 } })
|
||||||
|
api.apiGet.mockRejectedValueOnce (error)
|
||||||
|
await expect (switchWikiLocale ({
|
||||||
|
location: { pathname: '/ain-Kana/wiki/missing', search: '', hash: '' },
|
||||||
|
params: { title: 'missing' }, locale: 'ain-Kana', targetLocale: 'ja' }))
|
||||||
|
.rejects.toBe (error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each (['edit', 'diff'] as const) ('keeps the %s screen with the target Wiki ID',
|
||||||
|
async view => {
|
||||||
|
api.apiGet.mockResolvedValueOnce ({ id: 101, tagId: 7, resourceType: 'tag_description' })
|
||||||
|
api.apiGet.mockResolvedValueOnce ({ id: 202, tagId: 7, resourceType: 'tag_description' })
|
||||||
|
const path = await switchWikiLocale ({
|
||||||
|
location: { pathname: `/ja/wiki/101/${view}`, search: '?from=11&to=12&keep=1',
|
||||||
|
hash: '#section' },
|
||||||
|
params: { id: '101' }, locale: 'ja', targetLocale: 'ain-Kana' }, view)
|
||||||
|
expect (path).toBe (`/ain-Kana/wiki/202/${view}?keep=1#section`)
|
||||||
|
expect (api.apiGet).toHaveBeenNthCalledWith (2, '/wiki/101', {
|
||||||
|
locale: 'ain-Kana', params: { } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps filtered history with the target-language description ID', async () => {
|
||||||
|
api.apiGet.mockResolvedValueOnce ({ id: 101 })
|
||||||
|
api.apiGet.mockResolvedValueOnce ({ id: 202 })
|
||||||
|
const path = await switchWikiLocale ({
|
||||||
|
location: { pathname: '/ja/wiki/changes', search: '?id=101&keep=1', hash: '' },
|
||||||
|
params: { }, locale: 'ja', targetLocale: 'ain-Kana' }, 'history')
|
||||||
|
expect (path).toBe ('/ain-Kana/wiki/changes?id=202&keep=1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('fetches wiki index and show pages with expected parameters', async () => {
|
it ('fetches wiki index and show pages with expected parameters', async () => {
|
||||||
|
|||||||
+68
-4
@@ -1,9 +1,13 @@
|
|||||||
import { isAxiosError } from 'axios'
|
import { isAxiosError } from 'axios'
|
||||||
|
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
import { switchLocalePath } from '@/lib/locales'
|
||||||
|
import { fetchTag, fetchTagByName } from '@/lib/tags'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
import type { WikiAutolinkCandidate, WikiPage, WikiPageChange } from '@/types'
|
import type { LocaleSwitchRequest } from '@/lib/routeLocales'
|
||||||
|
import type { Tag, WikiAutolinkCandidate, WikiPage,
|
||||||
|
WikiPageChange, WikiTagReference } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
export const fetchWikiAutolinkCandidates = (
|
export const fetchWikiAutolinkCandidates = (
|
||||||
@@ -66,8 +70,68 @@ export const fetchWikiPageByReference = async (
|
|||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
if (isAxiosError (error) && error.response?.status === 404)
|
if (!isAxiosError (error) || error.response?.status !== 404)
|
||||||
return null
|
throw error
|
||||||
throw error
|
const tag = await fetchWikiTag ({ wikiId: reference }, locale)
|
||||||
|
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchWikiTag = async (
|
||||||
|
reference: WikiTagReference,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): Promise<Tag | null> => {
|
||||||
|
if ('name' in reference)
|
||||||
|
return fetchTagByName (reference.name, locale)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await apiGet (`/wiki/${encodeURIComponent (reference.wikiId)}/tag`, { locale })
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
if (!isAxiosError (error) || error.response?.status !== 404)
|
||||||
|
throw error
|
||||||
|
return fetchTagByName (reference.wikiId, locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const switchWikiLocale = async (
|
||||||
|
{ location, params, locale, targetLocale }: LocaleSwitchRequest,
|
||||||
|
view: 'detail' | 'edit' | 'diff' | 'history' = 'detail',
|
||||||
|
): Promise<string> => {
|
||||||
|
const query = new URLSearchParams (location.search)
|
||||||
|
const reference = params.title ?? params.id ?? query.get ('id')
|
||||||
|
if (reference == null)
|
||||||
|
return switchLocalePath (location, targetLocale)
|
||||||
|
const page = await fetchWikiPageByReference (reference, { }, locale)
|
||||||
|
const sourceTag = page == null ? await fetchWikiTag (
|
||||||
|
/^\d+$/.test (reference) ? { wikiId: reference } : { name: reference }, locale) : null
|
||||||
|
if (page == null && sourceTag == null)
|
||||||
|
return switchLocalePath (location, targetLocale)
|
||||||
|
const translated = page == null ? null
|
||||||
|
: await fetchWikiPageByReference (String (page.id), { }, targetLocale)
|
||||||
|
const tag = sourceTag != null
|
||||||
|
? await fetchTag (String (sourceTag.id), targetLocale)
|
||||||
|
: ((translated == null && page?.resourceType === 'tag_description')
|
||||||
|
? await fetchWikiTag ({ wikiId: String (page.id) }, targetLocale) : null)
|
||||||
|
const destination = tag?.name ?? translated?.id ?? page?.id
|
||||||
|
if (destination == null)
|
||||||
|
throw new Error ('Wiki resource could not be resolved')
|
||||||
|
for (const key of ['version', 'from', 'to'])
|
||||||
|
query.delete (key)
|
||||||
|
const historyId = view === 'history' ? translated?.id : null
|
||||||
|
if (historyId != null)
|
||||||
|
query.set ('id', String (historyId))
|
||||||
|
else
|
||||||
|
query.delete ('id')
|
||||||
|
const suffix = (translated != null && (view === 'edit' || view === 'diff'))
|
||||||
|
? `/${view}` : ''
|
||||||
|
return switchLocalePath ({
|
||||||
|
pathname: historyId != null
|
||||||
|
? location.pathname
|
||||||
|
: `/wiki/${encodeURIComponent (destination)}${suffix}`,
|
||||||
|
search: query.size === 0 ? '' : `?${query}`,
|
||||||
|
hash: location.hash }, targetLocale)
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const PostListPage: FC = () => {
|
|||||||
|
|
||||||
const keys = {
|
const keys = {
|
||||||
tags: tagsKey, match, page, limit,
|
tags: tagsKey, match, page, limit,
|
||||||
|
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
|
||||||
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
|
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
|
||||||
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
||||||
order } as const
|
order } as const
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ const PostSearchPage: FC = () => {
|
|||||||
|
|
||||||
const keys: FetchPostsParams = {
|
const keys: FetchPostsParams = {
|
||||||
tags: qTags, match: qMatch, page, limit,
|
tags: qTags, match: qMatch, page, limit,
|
||||||
|
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
|
||||||
url: qURL,
|
url: qURL,
|
||||||
title: qTitle,
|
title: qTitle,
|
||||||
originalCreatedFrom: qOriginalCreatedFrom,
|
originalCreatedFrom: qOriginalCreatedFrom,
|
||||||
|
|||||||
@@ -257,7 +257,14 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
|
|||||||
{nicoTags.length > 0
|
{nicoTags.length > 0
|
||||||
? (
|
? (
|
||||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||||
<table className="w-full table-auto border-collapse">
|
<table className="w-full min-w-[800px] table-auto border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-64"/>
|
||||||
|
<col/>
|
||||||
|
<col className="w-56"/>
|
||||||
|
<col className="w-56"/>
|
||||||
|
{editable && <col className="w-24"/>}
|
||||||
|
</colgroup>
|
||||||
<thead className="border-b-2 border-black dark:border-white
|
<thead className="border-b-2 border-black dark:border-white
|
||||||
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
|
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -72,9 +72,12 @@ const renderPrimaryName = (
|
|||||||
language: string,
|
language: string,
|
||||||
label: (key: string) => ReactNode,
|
label: (key: string) => ReactNode,
|
||||||
) => (
|
) => (
|
||||||
<span lang={language} className="whitespace-nowrap">
|
<span lang={language} className="whitespace-normal [overflow-wrap:anywhere]">
|
||||||
{snapshot.name}
|
{snapshot.name}
|
||||||
{snapshot.name != null && snapshot.autoGenerated && label ('nameChanges.uncertified')}
|
{snapshot.name != null && snapshot.autoGenerated && (
|
||||||
|
<span className="ainu-ui-label inline-block whitespace-nowrap">
|
||||||
|
{label ('nameChanges.uncertified')}
|
||||||
|
</span>)}
|
||||||
</span>)
|
</span>)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ const renderAliasDiff = (
|
|||||||
<ins
|
<ins
|
||||||
key={name}
|
key={name}
|
||||||
lang={language}
|
lang={language}
|
||||||
className="mr-2 inline-block whitespace-nowrap
|
className="mr-2 inline-block max-w-full whitespace-normal [overflow-wrap:anywhere]
|
||||||
underline text-green-600 dark:text-green-400">
|
underline text-green-600 dark:text-green-400">
|
||||||
{name}
|
{name}
|
||||||
</ins>)
|
</ins>)
|
||||||
@@ -105,7 +108,7 @@ const renderAliasDiff = (
|
|||||||
<del
|
<del
|
||||||
key={name}
|
key={name}
|
||||||
lang={language}
|
lang={language}
|
||||||
className="mr-2 inline-block whitespace-nowrap
|
className="mr-2 inline-block max-w-full whitespace-normal [overflow-wrap:anywhere]
|
||||||
line-through text-red-600 dark:text-red-400">
|
line-through text-red-600 dark:text-red-400">
|
||||||
{name}
|
{name}
|
||||||
</del>)
|
</del>)
|
||||||
@@ -115,7 +118,7 @@ const renderAliasDiff = (
|
|||||||
<span
|
<span
|
||||||
key={name}
|
key={name}
|
||||||
lang={language}
|
lang={language}
|
||||||
className="mr-2 inline-block whitespace-nowrap">
|
className="mr-2 inline-block max-w-full whitespace-normal [overflow-wrap:anywhere]">
|
||||||
{name}
|
{name}
|
||||||
</span>)
|
</span>)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -360,7 +360,18 @@ const TagListPage: FC = () => {
|
|||||||
{loading ? label ('common:loading') : (results.length > 0 ? (
|
{loading ? label ('common:loading') : (results.length > 0 ? (
|
||||||
<div className="mt-4 min-w-0 max-w-full">
|
<div className="mt-4 min-w-0 max-w-full">
|
||||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||||
<table className="w-full table-auto border-collapse">
|
<table className="w-full min-w-[2000px] table-auto border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-72"/>
|
||||||
|
<col className="w-16"/>
|
||||||
|
<col className="w-48"/>
|
||||||
|
<col className="w-32"/>
|
||||||
|
<col className="w-72"/>
|
||||||
|
<col className="w-48"/>
|
||||||
|
<col className="w-56"/>
|
||||||
|
<col className="w-56"/>
|
||||||
|
<col className="w-16"/>
|
||||||
|
</colgroup>
|
||||||
<thead className="border-b-2 border-black dark:border-white
|
<thead className="border-b-2 border-black dark:border-white
|
||||||
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
|
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -468,7 +479,8 @@ const TagListPage: FC = () => {
|
|||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={row.category === 'nico'
|
to={row.category === 'nico'
|
||||||
? `/posts/changes?external_tag=${ row.id }`
|
? `/posts/changes?external_tag=${ row.id }`
|
||||||
: `/posts/changes?tag=${ row.id }`}>
|
: `/posts/changes?tag=${ row.id }`}
|
||||||
|
className="ainu-ui-label inline-block whitespace-nowrap">
|
||||||
{label ('history')}
|
{label ('history')}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const postsApi = vi.hoisted (() => ({
|
|||||||
vi.mock ('@/lib/wiki', () => ({
|
vi.mock ('@/lib/wiki', () => ({
|
||||||
...wikiApi,
|
...wikiApi,
|
||||||
fetchWikiAutolinkCandidates: async () => [],
|
fetchWikiAutolinkCandidates: async () => [],
|
||||||
|
fetchWikiTag: (reference: { name?: string; wikiId?: string }, locale: string) =>
|
||||||
|
tagsApi.fetchTagByName (reference.name ?? reference.wikiId, locale),
|
||||||
fetchWikiPageByReference: (reference: string, ...args: unknown[]) =>
|
fetchWikiPageByReference: (reference: string, ...args: unknown[]) =>
|
||||||
/^\d+$/.test (reference)
|
/^\d+$/.test (reference)
|
||||||
? wikiApi.fetchWikiPage (reference, ...args)
|
? wikiApi.fetchWikiPage (reference, ...args)
|
||||||
@@ -118,7 +120,8 @@ describe ('WikiDetailPage', () => {
|
|||||||
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
|
||||||
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toEqual (page)
|
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toEqual (page)
|
||||||
expect (queryClient.getQueryState (wikiKeys.index ({ }))?.isInvalidated).toBe (true)
|
expect (queryClient.getQueryState (wikiKeys.index ({ }))?.isInvalidated).toBe (true)
|
||||||
await waitFor (() => expect (queryClient.getQueryData (tagsKeys.show (page.title)))
|
await waitFor (() => expect (queryClient.getQueryData (
|
||||||
|
wikiKeys.tag ({ name: page.title }, 'ja')))
|
||||||
.toMatchObject ({ hasWiki: true }))
|
.toMatchObject ({ hasWiki: true }))
|
||||||
expect (getHelpTitleIndex).toHaveBeenCalledTimes (1)
|
expect (getHelpTitleIndex).toHaveBeenCalledTimes (1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,14 +15,13 @@ import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
|||||||
import { getHelpTitleIndex } from '@/lib/help'
|
import { getHelpTitleIndex } from '@/lib/help'
|
||||||
import { localeLanguage } from '@/lib/locales'
|
import { localeLanguage } from '@/lib/locales'
|
||||||
import { fetchPosts } from '@/lib/posts'
|
import { fetchPosts } from '@/lib/posts'
|
||||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
import { helpKeys, postsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
import { fetchTagByName } from '@/lib/tags'
|
import { fetchWikiPageByReference, fetchWikiTag } from '@/lib/wiki'
|
||||||
import { fetchWikiPageByReference } from '@/lib/wiki'
|
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Tag, WikiTagReference } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
const WikiDetailPage: FC = () => {
|
const WikiDetailPage: FC = () => {
|
||||||
@@ -65,21 +64,24 @@ const WikiDetailPage: FC = () => {
|
|||||||
|
|
||||||
const effectiveTitle = wikiPage?.title ?? title
|
const effectiveTitle = wikiPage?.title ?? title
|
||||||
const deprecated = wikiPage?.deprecatedAt != null
|
const deprecated = wikiPage?.deprecatedAt != null
|
||||||
|
const tagReference: WikiTagReference = (wikiPage != null || !numericReference)
|
||||||
|
? { name: effectiveTitle } : { wikiId: title }
|
||||||
|
|
||||||
const { data: tag, isPending: tagPending,
|
const { data: tag, isPending: tagPending,
|
||||||
isFetching: tagFetching, error: tagError } = useQuery ({
|
isFetching: tagFetching, error: tagError } = useQuery ({
|
||||||
enabled: Boolean (effectiveTitle) && (!numericReference || wikiPage != null),
|
enabled: Boolean (title) && (missingWiki || wikiPage != null),
|
||||||
queryKey: tagsKeys.show (effectiveTitle, locale),
|
queryKey: wikiKeys.tag (tagReference, locale),
|
||||||
queryFn: () => fetchTagByName (effectiveTitle, locale) })
|
queryFn: () => fetchWikiTag (tagReference, locale) })
|
||||||
|
const displayTitle = wikiPage?.title ?? tag?.name ?? title
|
||||||
|
|
||||||
const keys = {
|
const keys = {
|
||||||
tags: effectiveTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
|
tags: displayTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
|
||||||
originalCreatedFrom: '', originalCreatedTo: '',
|
originalCreatedFrom: '', originalCreatedTo: '',
|
||||||
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
||||||
order: 'original_created_at:desc' } as const
|
order: 'original_created_at:desc' } as const
|
||||||
const { data } = useQuery ({
|
const { data } = useQuery ({
|
||||||
enabled: Boolean (effectiveTitle) && !(version)
|
enabled: Boolean (effectiveTitle) && !(version)
|
||||||
&& (!numericReference || wikiPage != null),
|
&& (!numericReference || wikiPage != null || tag != null),
|
||||||
queryKey: postsKeys.index (keys, locale),
|
queryKey: postsKeys.index (keys, locale),
|
||||||
queryFn: () => fetchPosts (keys, locale) })
|
queryFn: () => fetchPosts (keys, locale) })
|
||||||
const posts = data?.posts || []
|
const posts = data?.posts || []
|
||||||
@@ -103,12 +105,10 @@ const WikiDetailPage: FC = () => {
|
|||||||
if (displayError != null)
|
if (displayError != null)
|
||||||
return <MainArea><p role="alert">{displayError.message}</p></MainArea>
|
return <MainArea><p role="alert">{displayError.message}</p></MainArea>
|
||||||
|
|
||||||
if (missingWiki && numericReference)
|
|
||||||
return <NotFound/>
|
|
||||||
|
|
||||||
const waiting = loading || (isFetching && wikiPage == null)
|
const waiting = loading || (isFetching && wikiPage == null)
|
||||||
|| (missingWiki && (lookupPending || lookupFetching || tagPending
|
|| (missingWiki && ((!numericReference
|
||||||
|| (tagFetching && tag == null) || identifiers.length === 1))
|
&& (lookupPending || lookupFetching || identifiers.length === 1))
|
||||||
|
|| tagPending || (tagFetching && tag == null)))
|
||||||
|
|
||||||
if (missingWiki && !(waiting) && (tag == null || version != null))
|
if (missingWiki && !(waiting) && (tag == null || version != null))
|
||||||
return <NotFound/>
|
return <NotFound/>
|
||||||
@@ -116,7 +116,7 @@ const WikiDetailPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<PageHead title={{
|
<PageHead title={{
|
||||||
text: `${effectiveTitle}${deprecated
|
text: `${displayTitle}${deprecated
|
||||||
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
|
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
|
||||||
language: deprecated ? message ('labels.deprecatedSuffix').language : locale }}>
|
language: deprecated ? message ('labels.deprecatedSuffix').language : locale }}>
|
||||||
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
|
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
|
||||||
@@ -141,7 +141,7 @@ const WikiDetailPage: FC = () => {
|
|||||||
<article className="prose dark:prose-invert mx-auto p-4">
|
<article className="prose dark:prose-invert mx-auto p-4">
|
||||||
<h1 className="prose-a:no-underline">
|
<h1 className="prose-a:no-underline">
|
||||||
<TagLink tag={tag ?? { ...defaultTag,
|
<TagLink tag={tag ?? { ...defaultTag,
|
||||||
name: effectiveTitle,
|
name: displayTitle,
|
||||||
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
|
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
|
||||||
withWiki={false}
|
withWiki={false}
|
||||||
withCount={false}
|
withCount={false}
|
||||||
@@ -150,7 +150,7 @@ const WikiDetailPage: FC = () => {
|
|||||||
</h1>
|
</h1>
|
||||||
{waiting
|
{waiting
|
||||||
? label ('common:loading')
|
? label ('common:loading')
|
||||||
: <WikiBody title={title} body={wikiPage?.body}
|
: <WikiBody title={displayTitle} body={wikiPage?.body}
|
||||||
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
|
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
|
||||||
? localeLanguage (locale) : 'und')}/>}
|
? localeLanguage (locale) : 'und')}/>}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type FetchPostsParams = {
|
|||||||
url: string
|
url: string
|
||||||
title: string
|
title: string
|
||||||
tags: string
|
tags: string
|
||||||
|
tagReferences?: string
|
||||||
match: 'all' | 'any'
|
match: 'all' | 'any'
|
||||||
originalCreatedFrom: string
|
originalCreatedFrom: string
|
||||||
originalCreatedTo: string
|
originalCreatedTo: string
|
||||||
@@ -476,6 +477,8 @@ export type User = {
|
|||||||
|
|
||||||
export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior]
|
export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior]
|
||||||
|
|
||||||
|
export type WikiTagReference = { name: string } | { wikiId: string }
|
||||||
|
|
||||||
export type WikiPage = {
|
export type WikiPage = {
|
||||||
id: number
|
id: number
|
||||||
resourceType: 'tag_description' | 'tag_group'
|
resourceType: 'tag_description' | 'tag_group'
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export default defineConfig ({
|
|||||||
strictPort: true,
|
strictPort: true,
|
||||||
allowedHosts: ['hub.nizika.monster', 'localhost', 'nico-dev.test'],
|
allowedHosts: ['hub.nizika.monster', 'localhost', 'nico-dev.test'],
|
||||||
proxy: { '/api': { target: 'http://localhost:3002',
|
proxy: { '/api': { target: 'http://localhost:3002',
|
||||||
changeOrigin: true,
|
changeOrigin: false,
|
||||||
|
rewrite: requestPath => requestPath.replace (/^\/api/, ''),
|
||||||
secure: false } },
|
secure: false } },
|
||||||
watch: { usePolling: true,
|
watch: { usePolling: true,
|
||||||
interval: 100 } } })
|
interval: 100 } } })
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする