このコミットが含まれているのは:
@@ -1,4 +1,7 @@
|
||||
class ApplicationController < ActionController::API
|
||||
include ActionController::Cookies
|
||||
include ActionController::RequestForgeryProtection
|
||||
|
||||
class UnsupportedLocale < StandardError
|
||||
;
|
||||
end
|
||||
@@ -11,18 +14,39 @@ class ApplicationController < ActionController::API
|
||||
before_action :authenticate_user
|
||||
before_action :reject_banned_user!
|
||||
before_action :validate_explicit_locale!
|
||||
protect_from_forgery with: :exception
|
||||
self.forgery_protection_origin_check = true
|
||||
rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf
|
||||
after_action :prevent_authenticated_caching
|
||||
|
||||
def current_user = @current_user
|
||||
|
||||
private
|
||||
|
||||
def reject_invalid_csrf
|
||||
response.headers['X-CSRF-Invalid'] = '1'
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def authenticate_user
|
||||
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
|
||||
code = session[:inheritance_code]
|
||||
return if code.blank?
|
||||
|
||||
@current_user = User.find_by(inheritance_code: code)
|
||||
end
|
||||
|
||||
def prevent_authenticated_caching
|
||||
response.headers['Cache-Control'] = 'private, no-store' if current_user
|
||||
end
|
||||
|
||||
def establish_user_session user
|
||||
reset_session
|
||||
session[:inheritance_code] = user.inheritance_code
|
||||
@current_user = user
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
end
|
||||
|
||||
def bool? key, default: false
|
||||
return default if params[key].nil?
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class NicoTagsController < ApplicationController
|
||||
|
||||
q =
|
||||
ExternalTag
|
||||
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
|
||||
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " +
|
||||
'ON post_tag_max.external_tag_id = external_tags.id')
|
||||
.includes(linked_tags: [:tag_names, :tag_descriptions])
|
||||
if name
|
||||
@@ -37,7 +37,7 @@ class NicoTagsController < ApplicationController
|
||||
linked_tag_ids =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.where(tag_names: { language_code: 'ja', primary_flg: true })
|
||||
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
|
||||
.pluck(:id)
|
||||
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
|
||||
@@ -45,7 +45,7 @@ class NicoTagsController < ApplicationController
|
||||
end
|
||||
if link_status.in?(['linked', 'unlinked'])
|
||||
exists_sql =
|
||||
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
|
||||
'EXISTS (SELECT 1 FROM nico_tag_relations ' +
|
||||
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
|
||||
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
|
||||
end
|
||||
|
||||
@@ -400,8 +400,76 @@ class PostsController < ApplicationController
|
||||
render_post_form_record_invalid e.record
|
||||
end
|
||||
|
||||
def tag_filters
|
||||
source = resolve_locale!
|
||||
code = params[:target_locale]
|
||||
return head :not_found unless code.is_a?(String) && Locale.codes.value?(code)
|
||||
|
||||
target = Locale.find_by(code:, deprecated_at: nil)
|
||||
return head :not_found unless target
|
||||
|
||||
literals = params[:tags].to_s.split
|
||||
references = tag_filter_references(literals)
|
||||
names = literals.each_with_index.map do |literal, index|
|
||||
reference = references&.[](index)
|
||||
(reference && reference['negative'] == false) ? literal : literal.sub(/\Anot:/i, '')
|
||||
end
|
||||
internal = references ? {} : TagName.where(
|
||||
language_code: source.language_code, name: names).pluck(:name, :tag_id).to_h
|
||||
canonical = references ? {} : TagName.where(
|
||||
language_code: source.language_code, primary_flg: true,
|
||||
tag_id: internal.values).pluck(:tag_id, :name).to_h
|
||||
external_names = names.map { canonical[internal[_1]] || _1 }
|
||||
external = references ? {} : ExternalTag.where(
|
||||
"CONCAT(platform, ':', name) IN (?)", external_names).pluck(
|
||||
Arel.sql("CONCAT(platform, ':', name)"), :id).to_h
|
||||
ids = references ? references.filter_map { _1['tag_id'] } : internal.values
|
||||
translated = TagName.where(language_code: target.language_code,
|
||||
primary_flg: true, tag_id: ids).pluck(:tag_id, :name).to_h
|
||||
|
||||
filters = literals.each_with_index.map do |literal, index|
|
||||
name = names[index]
|
||||
reference = if references
|
||||
references[index].except('name')
|
||||
else
|
||||
matches = {}
|
||||
matches['tag_id'] = internal[name] if internal[name]
|
||||
external_id = external[external_names[index]]
|
||||
matches['external_tag_id'] = external_id if external_id
|
||||
matches.empty? ? { 'missing' => true } : matches
|
||||
end
|
||||
name = translated[reference['tag_id']] || name
|
||||
negative = reference.fetch('negative', literal.downcase.start_with?('not:'))
|
||||
prefix = negative ? literal[/\Anot:/i] || 'not:' : ''
|
||||
reference.merge('name' => "#{ prefix }#{ name }", 'negative' => negative)
|
||||
end
|
||||
render json: { references: filters }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def tag_filter_references literals
|
||||
raw = params[:tag_refs]
|
||||
return nil if raw.blank?
|
||||
|
||||
references = JSON.parse(raw)
|
||||
valid = references.is_a?(Array) && references.all? do |reference|
|
||||
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
|
||||
|
||||
ids = [reference['tag_id'], reference['external_tag_id']].compact
|
||||
((ids.any? || reference['missing'] == true) &&
|
||||
ids.all? { _1.is_a?(Integer) && _1.positive? } &&
|
||||
(!reference.key?('missing') || (reference['missing'] == true && ids.empty?)) &&
|
||||
(!reference.key?('negative') || [true, false].include?(reference['negative'])))
|
||||
end
|
||||
raise ActionController::BadRequest unless valid
|
||||
return nil unless references.map { _1['name'] } == literals
|
||||
|
||||
references
|
||||
rescue JSON::ParserError, TypeError
|
||||
raise ActionController::BadRequest
|
||||
end
|
||||
|
||||
def filtered_posts locale
|
||||
tag_names = params[:tags].to_s.split
|
||||
match_type = params[:match]
|
||||
@@ -413,20 +481,29 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def filter_posts_by_tags locale, tag_names, match_type
|
||||
literals = tag_names.map do |raw_name|
|
||||
{ name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first,
|
||||
negative: raw_name.downcase.start_with?('not:') }
|
||||
references = tag_filter_references(tag_names)
|
||||
literals = tag_names.each_with_index.map do |raw_name, index|
|
||||
name = raw_name.sub(/\Anot:/i, '')
|
||||
name = TagName.canonicalise(locale, name).first unless references
|
||||
reference = references&.[](index)
|
||||
negative = if reference&.key?('negative')
|
||||
reference['negative']
|
||||
else
|
||||
raw_name.downcase.start_with?('not:')
|
||||
end
|
||||
{ name:, negative:, reference: }
|
||||
end
|
||||
|
||||
return Post.all if literals.empty?
|
||||
|
||||
if match_type == 'any'
|
||||
literals.reduce(Post.none) do |posts, literal|
|
||||
posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative]))
|
||||
posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative],
|
||||
reference: literal[:reference]))
|
||||
end
|
||||
else
|
||||
literals.reduce(Post.all) do |posts, literal|
|
||||
ids = tagged_post_ids_for(locale, literal[:name])
|
||||
ids = tagged_post_ids_for(locale, literal[:name], reference: literal[:reference])
|
||||
if literal[:negative]
|
||||
posts.where.not(id: ids)
|
||||
else
|
||||
@@ -436,8 +513,8 @@ class PostsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def tag_literal_relation locale, name, negative:
|
||||
ids = tagged_post_ids_for(locale, name)
|
||||
def tag_literal_relation locale, name, negative:, reference: nil
|
||||
ids = tagged_post_ids_for(locale, name, reference:)
|
||||
if negative
|
||||
Post.where.not(id: ids)
|
||||
else
|
||||
@@ -445,7 +522,20 @@ class PostsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def tagged_post_ids_for locale, name
|
||||
def tagged_post_ids_for locale, name, reference: nil
|
||||
if reference
|
||||
matches = Post.none
|
||||
if reference['tag_id']
|
||||
matches = matches.or(Post.where(id: PostTag.where(
|
||||
tag_id: reference['tag_id']).select(:post_id)))
|
||||
end
|
||||
if reference['external_tag_id']
|
||||
matches = matches.or(Post.where(id: PostExternalTag.where(
|
||||
external_tag_id: reference['external_tag_id']).select(:post_id)))
|
||||
end
|
||||
return matches.select(:id)
|
||||
end
|
||||
|
||||
posts_by_internal_tags =
|
||||
Post
|
||||
.joins(tags: :tag_names)
|
||||
|
||||
@@ -307,7 +307,7 @@ class TagsController < ApplicationController
|
||||
.joins(:tag_names)
|
||||
.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
|
||||
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code })
|
||||
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||
|
||||
platform, external_name = name.split(':', 2)
|
||||
|
||||
@@ -1,45 +1,69 @@
|
||||
class UsersController < ApplicationController
|
||||
def create
|
||||
if current_user
|
||||
return render json: { code: current_user.inheritance_code,
|
||||
user: current_user.slice(:id, :name, :inheritance_code, :role) }
|
||||
end
|
||||
|
||||
user = nil
|
||||
User.transaction do
|
||||
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
|
||||
attach_ip_address!(user)
|
||||
end
|
||||
|
||||
establish_user_session(user)
|
||||
render json: { code: user.inheritance_code,
|
||||
user: user.slice(:id, :name, :inheritance_code, :role) },
|
||||
status: :created
|
||||
end
|
||||
|
||||
def verify
|
||||
user = User.find_by(inheritance_code: params[:code])
|
||||
user = if bool?(:restore) && current_user
|
||||
current_user
|
||||
else
|
||||
User.find_by(inheritance_code: params[:code])
|
||||
end
|
||||
return render json: { valid: false } unless user
|
||||
return head :forbidden if user.banned?
|
||||
|
||||
attach_ip_address!(user)
|
||||
establish_user_session(user)
|
||||
|
||||
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
||||
end
|
||||
|
||||
def session
|
||||
def show_session
|
||||
return head :unauthorized unless current_user
|
||||
|
||||
render json: current_user.slice(:id, :name, :role)
|
||||
end
|
||||
|
||||
def csrf
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
render json: { token: form_authenticity_token }
|
||||
end
|
||||
|
||||
def destroy_session
|
||||
reset_session
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def renew
|
||||
return head :unauthorized unless current_user
|
||||
|
||||
user = current_user
|
||||
user.inheritance_code = SecureRandom.uuid
|
||||
user.save!
|
||||
establish_user_session(user)
|
||||
render json: { code: user.inheritance_code }
|
||||
end
|
||||
|
||||
def me
|
||||
user = User.find_by(inheritance_code: params[:code])
|
||||
return head :not_found unless user
|
||||
user = current_user
|
||||
return head :unauthorized unless user
|
||||
|
||||
attach_ip_address!(user)
|
||||
render json: user.slice(:id, :name, :inheritance_code, :role)
|
||||
end
|
||||
|
||||
|
||||
@@ -55,9 +55,12 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
def show
|
||||
locale = resolve_locale!
|
||||
tag_description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
|
||||
reference = TagDescription.find_by(id: params[:id])
|
||||
tag_description = reference && TagDescription.find_by(
|
||||
tag_id: reference.tag_id, language_code: locale.language_code)
|
||||
|
||||
return render_wiki_page_by_tag_description locale, tag_description if tag_description
|
||||
return head :not_found if reference
|
||||
|
||||
tag_group = TagGroup.find_by(id: params[:id])
|
||||
return render_tag_group locale, tag_group if tag_group
|
||||
@@ -82,6 +85,17 @@ class WikiPagesController < ApplicationController
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def tag_reference
|
||||
locale = resolve_locale!
|
||||
description = TagDescription.find_by(id: params[:id])
|
||||
return head :not_found unless description
|
||||
|
||||
tag = Tag.preload(:tag_names, :materials, :deerjikists, :tag_descriptions,
|
||||
parents: [:tag_names, :materials, :deerjikists, :tag_descriptions])
|
||||
.find(description.tag_id)
|
||||
render json: TagRepr.base(tag, language_code: locale.language_code)
|
||||
end
|
||||
|
||||
def exists
|
||||
locale = resolve_locale!
|
||||
if TagDescription.exists?(id: params[:id], language_code: locale.language_code)
|
||||
|
||||
@@ -31,5 +31,14 @@ module Backend
|
||||
# Middleware like session, flash, cookies can be added back manually.
|
||||
# Skip views, helpers and assets when generating a new resource.
|
||||
config.api_only = true
|
||||
config.middleware.use ActionDispatch::Cookies
|
||||
config.session_store :cookie_store,
|
||||
key: Rails.env.production? ? '__Host-btrc-session' : 'btrc-session',
|
||||
path: '/',
|
||||
secure: Rails.env.production?,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
expire_after: 20.years
|
||||
config.middleware.use ActionDispatch::Session::CookieStore, config.session_options
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# Use this to limit dissemination of sensitive information.
|
||||
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
||||
Rails.application.config.filter_parameters += [
|
||||
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
|
||||
]
|
||||
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate,
|
||||
:otp, :ssn, :cvv, :cvc, :code, :inheritance_code]
|
||||
|
||||
@@ -56,12 +56,14 @@ Rails.application.routes.draw do
|
||||
member do
|
||||
get :exists
|
||||
get :diff
|
||||
get :tag, action: :tag_reference
|
||||
end
|
||||
end
|
||||
|
||||
resources :posts, only: [:index, :show, :create, :update] do
|
||||
collection do
|
||||
get :metadata
|
||||
get :tag_filters
|
||||
post :bulk
|
||||
get :random
|
||||
get :versions, to: 'post_versions#index'
|
||||
@@ -100,7 +102,9 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
post :verify
|
||||
get :me
|
||||
get :session
|
||||
get :session, action: :show_session
|
||||
get :csrf
|
||||
delete :session, action: :destroy_session
|
||||
post 'code/renew', action: :renew
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,6 +53,14 @@ RSpec.configure do |config|
|
||||
config.include AuthHelper, type: :request
|
||||
config.include JsonHelper, type: :request
|
||||
|
||||
config.around(:each, type: :request) do |example|
|
||||
previous = ApplicationController.allow_forgery_protection
|
||||
ApplicationController.allow_forgery_protection = example.metadata.fetch(:csrf, false)
|
||||
example.run
|
||||
ensure
|
||||
ApplicationController.allow_forgery_protection = previous
|
||||
end
|
||||
|
||||
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
|
||||
config.fixture_paths = [
|
||||
Rails.root.join('spec/fixtures')
|
||||
|
||||
@@ -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 =
|
||||
count_sql_queries do
|
||||
get "/posts/#{ post_record.id }",
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
headers: cookie_headers(user)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
@@ -4,7 +4,7 @@ RSpec.describe 'Tag description Wiki facade writes', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'description_write') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
|
||||
def stored_state
|
||||
[Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model|
|
||||
|
||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
let(:editor) { create(:user, :member, name: 'description editor') }
|
||||
let(:tag_name) { create(:tag_name, name: 'native_description') }
|
||||
let(:tag) { create(:tag, primary_tag_name: tag_name) }
|
||||
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
|
||||
let(:headers) { cookie_headers(editor) }
|
||||
let(:endpoint) { "/tags/#{ tag.id }/description" }
|
||||
|
||||
def stored_state
|
||||
@@ -285,7 +285,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
guest = create(:user, :guest)
|
||||
before_state = stored_state
|
||||
native_write(body: 'forbidden',
|
||||
request_headers: { 'X-Transfer-Code' => guest.inheritance_code })
|
||||
request_headers: cookie_headers(guest))
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
@@ -294,7 +294,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
banned = create(:user, :member, :banned)
|
||||
before_state = stored_state
|
||||
native_write(body: 'forbidden',
|
||||
request_headers: { 'X-Transfer-Code' => banned.inheritance_code })
|
||||
request_headers: cookie_headers(banned))
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'japanese_member') }
|
||||
let!(:ainu_name) { add_ainu_name(tag, 'ainu_member') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let(:members) { [{ 'tag_id' => tag.id }] }
|
||||
let(:placements) do
|
||||
[{ 'section_name' => 'section', 'description' => nil, 'children' => [
|
||||
|
||||
@@ -37,7 +37,7 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
|
||||
markdown.lines.map(&:chomp).reject(&:empty?))
|
||||
|
||||
post '/wiki', params: { title: 'タググループ:再解析', body: },
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
headers: cookie_headers(user)
|
||||
expect(response).to have_http_status(:created)
|
||||
parsed = group_rows('tag_groups', name: '再解析').sole
|
||||
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
|
||||
|
||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('洗澡鹿') }
|
||||
let!(:other_tag) { group_tag('伊地知ニジカ') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
@@ -148,7 +148,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
body = group_markdown(page)
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id) do
|
||||
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
||||
update_group(body:, request_headers: cookie_headers(editor))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -212,7 +212,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
it 'records the editor and update time' do
|
||||
editor = create(:user, :member, name: '編輯者')
|
||||
travel_to(Time.current + 1.hour) do
|
||||
update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
||||
update_group(request_headers: cookie_headers(editor))
|
||||
expect(response).to have_http_status(:ok)
|
||||
current = group_rows('tag_groups', id: page.id).sole
|
||||
version = group_version(page.id, 2)
|
||||
@@ -271,7 +271,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
request_headers = actor ? cookie_headers(actor) : { }
|
||||
before_state = group_state
|
||||
write_group(operation, request_headers)
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
|
||||
@@ -8,7 +8,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('洗澡鹿') }
|
||||
let!(:other_tag) { group_tag('伊地知ニジカ') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
@@ -141,7 +141,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
end
|
||||
let!(:id) { seed_group(name: '更新', members:, placements:) }
|
||||
let!(:editor) { create(:user, :member) }
|
||||
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
|
||||
let(:headers) { cookie_headers(editor) }
|
||||
|
||||
it 'keeps all membership and placement audit fields on a name-only edit' do
|
||||
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||
|
||||
@@ -47,7 +47,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
headers = actor ? cookie_headers(actor) : { }
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
@@ -61,7 +61,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
|
||||
it "rejects a banned IP for native #{ operation }" do
|
||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||
headers = { 'X-Transfer-Code' => user.inheritance_code }
|
||||
headers = cookie_headers(user)
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
@@ -77,7 +77,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
let!(:tag) { group_tag('掲載タグ') }
|
||||
let!(:unplaced) { group_tag('未掲載タグ') }
|
||||
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let(:members) do
|
||||
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
|
||||
end
|
||||
@@ -300,7 +300,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:) do
|
||||
put "/tag_groups/#{ group_id }", params: synchronised_payload,
|
||||
headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json
|
||||
headers: cookie_headers(editor), as: :json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe 'Tag resource ID allocation', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
|
||||
def create_description name
|
||||
tag = create(:tag, primary_name: name)
|
||||
|
||||
@@ -10,10 +10,16 @@ RSpec.describe 'Users', type: :request do
|
||||
end
|
||||
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
describe 'GET /users/session' do
|
||||
it 'does not accept the retired transfer-code header' do
|
||||
user = create(:user)
|
||||
get '/users/session', headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'requires authentication' do
|
||||
get '/users/session'
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
@@ -251,17 +257,102 @@ RSpec.describe 'Users', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Cookie sessions with CSRF enabled', csrf: true do
|
||||
def csrf_headers
|
||||
get '/users/csrf'
|
||||
expect(response).to have_http_status(:ok)
|
||||
{ 'X-CSRF-Token' => json.fetch('token'), 'Origin' => 'http://www.example.com' }
|
||||
end
|
||||
|
||||
def establish_session(user)
|
||||
post '/users/verify', params: { code: user.inheritance_code }, headers: csrf_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('valid')).to be(true)
|
||||
end
|
||||
|
||||
it 'rejects a write without a token before creating an account' do
|
||||
expect { post '/users' }.not_to change(User, :count)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.headers['X-CSRF-Invalid']).to eq('1')
|
||||
end
|
||||
|
||||
it 'uses the encrypted persistent Cookie for CSR and SSR session reads' do
|
||||
user = create(:user)
|
||||
establish_session(user)
|
||||
cookie = Array(response.headers['set-cookie']).join
|
||||
expect(cookie).to include('httponly', 'samesite=lax')
|
||||
expect(cookie).not_to include(user.inheritance_code)
|
||||
expires = Time.httpdate(cookie.match(/expires=([^;]+)/i)[1])
|
||||
expect(expires).to be > 19.years.from_now
|
||||
get '/users/session'
|
||||
expect(json.fetch('id')).to eq(user.id)
|
||||
expect(response.body).not_to include(user.inheritance_code)
|
||||
get '/users/me'
|
||||
expect(json.fetch('inheritance_code')).to eq(user.inheritance_code)
|
||||
expect(response.headers['Cache-Control']).to include('no-store')
|
||||
end
|
||||
|
||||
it 'rejects a cross-origin write with an otherwise valid token' do
|
||||
headers = csrf_headers.merge('Origin' => 'https://other.invalid')
|
||||
expect { post '/users', headers: }.not_to change(User, :count)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
it 'does not replace an existing Cookie identity during recovery' do
|
||||
current = create(:user)
|
||||
old = create(:user)
|
||||
establish_session(current)
|
||||
post '/users/verify', params: { code: old.inheritance_code, restore: true },
|
||||
headers: csrf_headers
|
||||
expect(json.fetch('user').fetch('id')).to eq(current.id)
|
||||
get '/users/session'
|
||||
expect(json.fetch('id')).to eq(current.id)
|
||||
end
|
||||
|
||||
it 'allows explicit transfer and refreshes the active session' do
|
||||
first = create(:user)
|
||||
second = create(:user)
|
||||
establish_session(first)
|
||||
establish_session(second)
|
||||
get '/users/session'
|
||||
expect(json.fetch('id')).to eq(second.id)
|
||||
end
|
||||
|
||||
it 'keeps the renewed session and rejects the previous credential' do
|
||||
user = create(:user)
|
||||
establish_session(user)
|
||||
old_cookie = Array(response.headers['set-cookie']).first.split(';').first
|
||||
old_code = user.inheritance_code
|
||||
post '/users/code/renew', headers: csrf_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('code')).not_to eq(old_code)
|
||||
get '/users/session'
|
||||
expect(json.fetch('id')).to eq(user.id)
|
||||
other_browser = ActionDispatch::Integration::Session.new(Rails.application)
|
||||
other_browser.get('/users/session', headers: { 'Cookie' => old_cookie })
|
||||
expect(other_browser.response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'clears browser authentication on session deletion' do
|
||||
establish_session(create(:user))
|
||||
delete '/users/session', headers: csrf_headers
|
||||
expect(response).to have_http_status(:no_content)
|
||||
get '/users/session'
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /users/me' do
|
||||
it 'returns 404 when code not found' do
|
||||
it 'requires a session even when a code is supplied in the query' do
|
||||
get '/users/me', params: { code: 'nope' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns user slice when found' do
|
||||
user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest')
|
||||
|
||||
get '/users/me', params: { code: user.inheritance_code }
|
||||
get '/users/me', headers: cookie_headers(user)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['id']).to eq(user.id)
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
it 'returns 409 when base_revision_id is stale' do
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do
|
||||
include WikiRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
|
||||
|
||||
context 'existing description write protection' do
|
||||
@@ -18,7 +18,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do
|
||||
it "rejects #{ role || 'anonymous' } without changing either history" do
|
||||
actor = role && create(:user, role == :banned ? :member : role)
|
||||
actor.update!(banned_at: Time.current) if role == :banned
|
||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
request_headers = actor ? cookie_headers(actor) : { }
|
||||
before_state = group_state
|
||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
|
||||
headers: request_headers
|
||||
|
||||
@@ -8,7 +8,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'japanese_title') }
|
||||
let!(:ainu_name) { add_ainu_name(tag, 'ainu_title') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let!(:japanese) do
|
||||
TagDescriptionOperation.create!(
|
||||
Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user)
|
||||
|
||||
@@ -6,7 +6,7 @@ require 'securerandom'
|
||||
RSpec.describe 'Wiki API', type: :request do
|
||||
include WikiRecords
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
@@ -80,7 +80,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
let(:guest) { create(:user, role: 'guest') }
|
||||
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
context 'when not logged in' do
|
||||
@@ -126,7 +126,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
let(:guest) { create(:user, role: 'guest', inheritance_code: SecureRandom.hex(16)) }
|
||||
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki title collision', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
def create_wiki_page title:, body:
|
||||
|
||||
@@ -17,7 +17,7 @@ RSpec.describe 'Removed standalone Wiki resources', type: :request do
|
||||
user
|
||||
before_state = group_state
|
||||
post '/wiki', params: { title:, body: 'body' },
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
headers: cookie_headers(user)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
module AuthHelper
|
||||
def cookie_headers(user)
|
||||
env = Rails.application.env_config.merge(Rack::MockRequest.env_for('/'))
|
||||
endpoint = lambda do |request_env|
|
||||
ActionDispatch::Request.new(request_env).session[:inheritance_code] = user.inheritance_code
|
||||
[200, {}, []]
|
||||
end
|
||||
store = ActionDispatch::Session::CookieStore.new(
|
||||
endpoint, Rails.application.config.session_options)
|
||||
headers = ActionDispatch::Cookies.new(store).call(env)[1]
|
||||
{ 'Cookie' => Array(headers['set-cookie']).map { _1.split(';').first }.join('; ') }
|
||||
end
|
||||
|
||||
def sign_in_as(user)
|
||||
allow_any_instance_of(ApplicationController)
|
||||
.to receive(:current_user).and_return(user)
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする