Browse Source

Merge remote-tracking branch 'origin/main' into feature/047

feature/047
みてるぞ 2 days ago
parent
commit
4f5812d737
21 changed files with 774 additions and 128 deletions
  1. +18
    -3
      backend/app/controllers/application_controller.rb
  2. +48
    -2
      backend/app/controllers/tags_controller.rb
  3. +1
    -5
      backend/app/controllers/users_controller.rb
  4. +3
    -7
      backend/app/models/ip_address.rb
  5. +2
    -0
      backend/app/models/tag.rb
  6. +5
    -8
      backend/app/models/user.rb
  7. +1
    -1
      backend/app/representations/tag_repr.rb
  8. +1
    -0
      backend/config/routes.rb
  9. +16
    -0
      backend/db/migrate/20260501153900_rename_banned_to_banned_at_in_users_and_ip_addresses.rb
  10. +10
    -0
      backend/spec/factories/ip_addresses.rb
  11. +12
    -3
      backend/spec/factories/users.rb
  12. +227
    -17
      backend/spec/requests/tags_deerjikists_spec.rb
  13. +213
    -56
      backend/spec/requests/users_spec.rb
  14. +2
    -4
      backend/spec/support/test_records.rb
  15. +2
    -0
      frontend/src/App.tsx
  16. +32
    -14
      frontend/src/components/TagLink.tsx
  17. +6
    -1
      frontend/src/consts.ts
  18. +6
    -5
      frontend/src/lib/queryKeys.ts
  19. +7
    -1
      frontend/src/lib/tags.ts
  20. +155
    -0
      frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx
  21. +7
    -1
      frontend/src/types.ts

+ 18
- 3
backend/app/controllers/application_controller.rb View File

@@ -1,14 +1,16 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
before_action :reject_banned_ip_address!
before_action :authenticate_user before_action :authenticate_user
before_action :reject_banned_user!


def current_user
@current_user
end
def current_user = @current_user


private private


def authenticate_user def authenticate_user
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE'] code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
return if code.blank?

@current_user = User.find_by(inheritance_code: code) @current_user = User.find_by(inheritance_code: code)
end end


@@ -22,4 +24,17 @@ class ApplicationController < ActionController::API
s.in?(['', '1', 'true', 'on', 'yes']) s.in?(['', '1', 'true', 'on', 'yes'])
end end
end end

def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?

head :forbidden
end

def reject_banned_user!
return unless current_user&.banned?

head :forbidden
end
end end

+ 48
- 2
backend/app/controllers/tags_controller.rb View File

@@ -1,3 +1,7 @@
require 'net/http'
require 'uri'


class TagsController < ApplicationController class TagsController < ApplicationController
def index def index
post_id = params[:post] post_id = params[:post]
@@ -182,7 +186,8 @@ class TagsController < ApplicationController
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless tag return head :not_found unless tag


render json: DeerjikistRepr.many(tag.deerjikists)
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end end


def deerjikists_by_name def deerjikists_by_name
@@ -194,7 +199,31 @@ class TagsController < ApplicationController
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
return head :not_found unless tag return head :not_found unless tag


render json: DeerjikistRepr.many(tag.deerjikists)
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end

def update_deerjikists
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?

tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
return head :not_found unless tag

ApplicationRecord.transaction do
tag.deerjikists = []
params[:_json].each do
platform = _1[:platform]
code = normalise_deerjikist_code(platform, _1[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
deerjikist.save!
end
end

render json: DeerjikistRepr.many(tag.reload.deerjikists)
end end


def materials_by_name def materials_by_name
@@ -391,4 +420,21 @@ class TagsController < ApplicationController
TagImplication.create!(tag:, parent_tag:) TagImplication.create!(tag:, parent_tag:)
end end
end end

def normalise_deerjikist_code platform, code
return code if platform != 'youtube' || code[0] != '@'

url = "https://www.youtube.com/#{ code }"

html = Net::HTTP.get(URI(url))

canonical = html[
/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})"/,
1]
return canonical if canonical

html[/"channelId":"(UC[a-zA-Z0-9_-]{22})"/, 1] || html[/\bUC[a-zA-Z0-9_-]{22}\b/]
rescue
nil
end
end end

+ 1
- 5
backend/app/controllers/users_controller.rb View File

@@ -1,9 +1,6 @@
class UsersController < ApplicationController class UsersController < ApplicationController
def create def create
return head :unprocessable_entity if request.remote_ip.blank?

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)
@@ -17,8 +14,7 @@ class UsersController < ApplicationController
def verify def verify
user = User.find_by(inheritance_code: params[:code]) user = User.find_by(inheritance_code: params[:code])
return render json: { valid: false } unless user return render json: { valid: false } unless user

return head :unprocessable_entity if request.remote_ip.blank?
return head :forbidden if user.banned?


attach_ip_address!(user) attach_ip_address!(user)




+ 3
- 7
backend/app/models/ip_address.rb View File

@@ -4,11 +4,7 @@ class IpAddress < ApplicationRecord
has_many :user_ips, dependent: :destroy has_many :user_ips, dependent: :destroy
has_many :users, through: :user_ips has_many :users, through: :user_ips


def banned? = banned_at?
def banned = banned?

def banned= value
bool = ActiveModel::Type::Boolean.new.cast(value)
self.banned_at = bool ? banned_at || Time.current : nil
end
def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end end

+ 2
- 0
backend/app/models/tag.rb View File

@@ -79,6 +79,8 @@ class Tag < ApplicationRecord


def material_id = materials.first&.id def material_id = materials.first&.id


def has_deerjikists = deerjikists.present?

def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)


+ 5
- 8
backend/app/models/user.rb View File

@@ -17,14 +17,11 @@ class User < ApplicationRecord
has_many :updated_wiki_pages, has_many :updated_wiki_pages,
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify


def banned? = banned_at?
def banned = banned?

def banned= value
bool = ActiveModel::Type::Boolean.new.cast(value)
self.banned_at = bool ? (banned_at || Time.current) : nil
end

def viewed?(post) = user_post_views.exists?(post_id: post.id) def viewed?(post) = user_post_views.exists?(post_id: post.id)

def gte_member? = member? || admin? def gte_member? = member? || admin?

def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end end

+ 1
- 1
backend/app/representations/tag_repr.rb View File

@@ -3,7 +3,7 @@


module TagRepr module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki, :material_id] }.freeze
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze


module_function module_function




+ 1
- 0
backend/config/routes.rb View File

@@ -24,6 +24,7 @@ Rails.application.routes.draw do
patch '', action: :update patch '', action: :update


get :deerjikists get :deerjikists
put :deerjikists, action: :update_deerjikists
end end
end end




+ 16
- 0
backend/db/migrate/20260501153900_rename_banned_to_banned_at_in_users_and_ip_addresses.rb View File

@@ -0,0 +1,16 @@
class RenameBannedToBannedAtInUsersAndIpAddresses < ActiveRecord::Migration[8.0]
def up
[:users, :ip_addresses].each do
add_column _1, :banned_at, :datetime, after: :banned
add_index _1, :banned_at
remove_column _1, :banned
end
end

def down
[:ip_addresses, :users].each do
add_column _1, :banned, :boolean, null: false, default: false, after: :banned_at
remove_column _1, :banned_at
end
end
end

+ 10
- 0
backend/spec/factories/ip_addresses.rb View File

@@ -0,0 +1,10 @@
FactoryBot.define do
factory :ip_address do
ip_address { IPAddr.new('203.0.113.10').hton }
banned_at { nil }

trait :banned do
banned_at { Time.current }
end
end
end

+ 12
- 3
backend/spec/factories/users.rb View File

@@ -1,15 +1,24 @@
FactoryBot.define do FactoryBot.define do
factory :user do factory :user do
name { "test-user" }
name { nil }
inheritance_code { SecureRandom.uuid } inheritance_code { SecureRandom.uuid }
role { "guest" }
role { 'guest' }
banned_at { nil }

trait :guest do
role { 'guest' }
end


trait :member do trait :member do
role { "member" }
role { 'member' }
end end


trait :admin do trait :admin do
role { 'admin' } role { 'admin' }
end end

trait :banned do
banned_at { Time.current }
end
end end
end end

+ 227
- 17
backend/spec/requests/tags_deerjikists_spec.rb View File

@@ -8,23 +8,35 @@ RSpec.describe 'Tags deerjikists API', type: :request do


let!(:tag) { create(:tag, category: :deerjikist) } let!(:tag) { create(:tag, category: :deerjikist) }


let(:member) { create(:user, :member) }
let(:guest) { create(:user, role: :guest) }

before do before do
# show_by_name / deerjikists_by_name 用に名前を固定
tag.tag_name.update!(name: 'deerjika') tag.tag_name.update!(name: 'deerjika')
end end


describe 'GET /tags/:id/deerjikists' do describe 'GET /tags/:id/deerjikists' do
subject(:do_request) do subject(:do_request) do
get "/tags/#{ tag_id }/deerjikists"
get "/tags/#{tag_id}/deerjikists"
end end


let(:tag_id) { tag.id } let(:tag_id) { tag.id }


context 'when tag exists and has no deerjikists' do context 'when tag exists and has no deerjikists' do
it 'returns 200 and empty array' do
it 'returns 200 with tag and empty deerjikists array' do
do_request do_request

expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to eq([])

expect(json).to be_a(Hash)

expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)

expect(json['deerjikists']).to eq([])
end end
end end


@@ -34,17 +46,27 @@ RSpec.describe 'Tags deerjikists API', type: :request do
Deerjikist.create!(platform: platform2, code: code2, tag: tag) Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end end


it 'returns 200 and deerjikists array' do
it 'returns 200 with tag and deerjikists array' do
do_request do_request

expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)


expect(json).to be_a(Array)
expect(json.size).to eq(2)
expect(json).to be_a(Hash)


expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly(
[platform1, code1],
[platform2, code2],
)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)

expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(2)

expect(json['deerjikists'].map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end end
end end


@@ -53,6 +75,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do


it 'returns 404' do it 'returns 404' do
do_request do_request

expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
@@ -60,7 +83,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do


describe 'GET /tags/name/:name/deerjikists' do describe 'GET /tags/name/:name/deerjikists' do
subject(:do_request) do subject(:do_request) do
get "/tags/name/#{ name }/deerjikists"
get "/tags/name/#{name}/deerjikists"
end end


let(:name) { 'deerjika' } let(:name) { 'deerjika' }
@@ -70,6 +93,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do


it 'returns 400' do it 'returns 400' do
do_request do_request

expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end end
end end
@@ -79,23 +103,209 @@ RSpec.describe 'Tags deerjikists API', type: :request do


it 'returns 404' do it 'returns 404' do
do_request do_request

expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end


context 'when tag exists and has no deerjikists' do
it 'returns 200 with tag and empty deerjikists array' do
do_request

expect(response).to have_http_status(:ok)

expect(json).to be_a(Hash)

expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)

expect(json['deerjikists']).to eq([])
end
end

context 'when tag exists and has deerjikists' do context 'when tag exists and has deerjikists' do
before do before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag) Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end end


it 'returns 200 and deerjikists array' do
it 'returns 200 with tag and deerjikists array' do
do_request do_request

expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)


expect(json).to be_a(Array)
expect(json.size).to eq(1)
expect(json[0]['platform']).to eq(platform1)
expect(json[0]['code']).to eq(code1)
expect(json).to be_a(Hash)

expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)

expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(1)

expect(json['deerjikists'][0]['platform']).to eq(platform1)
expect(json['deerjikists'][0]['code']).to eq(code1)
end
end
end

describe 'PUT /tags/:id/deerjikists' do
subject(:do_request) do
put "/tags/#{tag_id}/deerjikists", params: payload, as: :json
end

let(:tag_id) { tag.id }
let(:payload) do
[
{ platform: platform1, code: code1 },
{ platform: platform2, code: code2 },
]
end

context 'when not logged in' do
it 'returns 401' do
do_request

expect(response).to have_http_status(:unauthorized)
end
end

context 'when logged in but not member' do
before do
sign_in_as guest
end

it 'returns 403' do
do_request

expect(response).to have_http_status(:forbidden)
end
end

context 'when tag does not exist' do
let(:tag_id) { 9_999_999 }

before do
sign_in_as member
end

it 'returns 404' do
do_request

expect(response).to have_http_status(:not_found)
end
end

context 'when logged in as member' do
before do
sign_in_as member
end

context 'when tag has no deerjikists' do
it 'creates deerjikists and returns deerjikists array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(0).to(2)

expect(response).to have_http_status(:ok)

expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)

expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end

context 'when tag already has deerjikists' do
before do
Deerjikist.create!(platform: platform1, code: 'old-code-1', tag: tag)
Deerjikist.create!(platform: platform2, code: 'old-code-2', tag: tag)
end

it 'replaces deerjikists and returns deerjikists array' do
do_request

expect(response).to have_http_status(:ok)

expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)

expect(Deerjikist.exists?(platform: platform1, code: 'old-code-1')).to eq(false)
expect(Deerjikist.exists?(platform: platform2, code: 'old-code-2')).to eq(false)

expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end

context 'when payload is empty array' do
let(:payload) { [] }

before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end

it 'clears deerjikists and returns empty array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(2).to(0)

expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end

context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do
[
{ platform: 'youtube', code: '@deerjika' },
]
end

before do
allow(Net::HTTP).to receive(:get).and_return(
%(<link rel="canonical" href="https://www.youtube.com/channel/#{channel_id}">),
)
end

it 'normalises youtube handle to channel id' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(0).to(1)

expect(response).to have_http_status(:ok)

expect(Net::HTTP).to have_received(:get)

expect(Deerjikist.exists?(platform: 'youtube', code: channel_id, tag: tag))
.to eq(true)

expect(json).to be_a(Array)
expect(json.size).to eq(1)
expect(json[0]['platform']).to eq('youtube')
expect(json[0]['code']).to eq(channel_id)
end
end end
end end
end end


+ 213
- 56
backend/spec/requests/users_spec.rb View File

@@ -1,109 +1,266 @@
require "rails_helper"
require 'rails_helper'

RSpec.describe 'Users', type: :request do
let(:remote_ip) { '203.0.113.10' }

before do
allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return(remote_ip)
end

def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code }
end

describe 'POST /users' do
it 'creates guest user, IpAddress and UserIp, and returns code' do
expect {
post '/users'
}.to change(User, :count).by(1)
.and change(IpAddress, :count).by(1)
.and change(UserIp, :count).by(1)


RSpec.describe "Users", type: :request do
describe "POST /users" do
it "creates guest user and returns code" do
post "/users"
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(json["code"]).to be_present
expect(json["user"]["role"]).to eq("guest")
expect(json['code']).to be_present
expect(json['user']['role']).to eq('guest')

user = User.last
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)

expect(user.role).to eq('guest')
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end

it 'returns 403 and does not create user when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

expect {
post '/users'
}.not_to change(User, :count)

expect(response).to have_http_status(:forbidden)
expect(UserIp.count).to eq(0)
end end
end end


describe "POST /users/code/renew" do
it "returns 401 when not logged in" do
sign_out
post "/users/code/renew"
describe 'POST /users/code/renew' do
it 'returns 401 when not logged in' do
post '/users/code/renew'
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end

it 'returns 403 when current user is banned' do
user = create(:user, :banned)

post '/users/code/renew', headers: auth_headers(user)

expect(response).to have_http_status(:forbidden)
end

it 'returns 403 when current IP address is banned' do
user = create(:user)

IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

post '/users/code/renew', headers: auth_headers(user)

expect(response).to have_http_status(:forbidden)
end
end end


describe "PUT /users/:id" do
let(:user) { create(:user, name: "old-name", role: "guest") }
describe 'PUT /users/:id' do
let(:user) { create(:user, name: 'old-name', role: 'guest') }

it 'returns 401 when current_user id mismatch' do
other_user = create(:user)

put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(other_user)


it "returns 401 when current_user id mismatch" do
sign_in_as(create(:user))
put "/users/#{user.id}", params: { name: "new-name" }
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end


it "returns 400 when name is blank" do
sign_in_as(user)
put "/users/#{user.id}", params: { name: " " }
it 'returns 400 when name is blank' do
put "/users/#{user.id}",
params: { name: ' ' },
headers: auth_headers(user)

expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end end


it "updates name and returns 201 with user slice" do
sign_in_as(user)
put "/users/#{user.id}", params: { name: "new-name" }
it 'updates name and returns user slice' do
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_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["name"]).to eq("new-name")
expect(json['id']).to eq(user.id)
expect(json['name']).to eq('new-name')


user.reload user.reload
expect(user.name).to eq("new-name")
expect(user.name).to eq('new-name')
end

it 'returns 403 when current user is banned' do
user.update!(banned_at: Time.current)

put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)

expect(response).to have_http_status(:forbidden)

user.reload
expect(user.name).to eq('old-name')
end

it 'returns 403 when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)

expect(response).to have_http_status(:forbidden)

user.reload
expect(user.name).to eq('old-name')
end end
end end


describe "POST /users/verify" do
it "returns valid:false when code not found" do
post "/users/verify", params: { code: "nope" }
describe 'POST /users/verify' do
it 'returns valid:false when code not found' do
post '/users/verify', params: { code: 'nope' }

expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(false)
expect(json['valid']).to eq(false)
end end


it "creates IpAddress and UserIp, and returns valid:true with user slice" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')

IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)

expect(response).to have_http_status(:forbidden)
end

it 'returns 403 when verified user is banned' do
user = create(
:user,
:banned,
inheritance_code: SecureRandom.uuid,
role: 'guest'
)

expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)

expect(response).to have_http_status(:forbidden)
end


# request.remote_ip を固定
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
it 'creates IpAddress and UserIp, and returns valid:true with user slice' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')


expect { expect {
post "/users/verify", params: { code: user.inheritance_code }
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1) }.to change(UserIp, :count).by(1)
.and change(IpAddress, :count).by(1)


expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true)
expect(json["user"]["id"]).to eq(user.id)
expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code)
expect(json["user"]["role"]).to eq("guest")
expect(json['valid']).to eq(true)
expect(json['user']['id']).to eq(user.id)
expect(json['user']['inheritance_code']).to eq(user.inheritance_code)
expect(json['user']['role']).to eq('guest')


# ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる)
expect(IpAddress.count).to be >= 1
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end end


it "is idempotent for same user+ip (does not create duplicate UserIp)" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
it 'is idempotent for same user and same IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')


post "/users/verify", params: { code: user.inheritance_code }
post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)


expect { expect {
post "/users/verify", params: { code: user.inheritance_code }
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count) }.not_to change(UserIp, :count)


expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true)
expect(json['valid']).to eq(true)
end

it 'creates another UserIp for same user and different IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')

post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)

allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return('203.0.113.11')

expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1)

expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true)
end end
end end


describe "GET /users/me" do
it "returns 404 when code not found" do
get "/users/me", params: { code: "nope" }
describe 'GET /users/me' do
it 'returns 404 when code not found' do
get '/users/me', params: { code: 'nope' }

expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end 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 }
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 }


expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json["id"]).to eq(user.id)
expect(json["name"]).to eq("me")
expect(json["inheritance_code"]).to eq(user.inheritance_code)
expect(json["role"]).to eq("guest")
expect(json['id']).to eq(user.id)
expect(json['name']).to eq('me')
expect(json['inheritance_code']).to eq(user.inheritance_code)
expect(json['role']).to eq('guest')
end

it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid)

IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)

get '/users/me', params: { code: user.inheritance_code }

expect(response).to have_http_status(:forbidden)
end end
end end
end end

+ 2
- 4
backend/spec/support/test_records.rb View File

@@ -2,14 +2,12 @@ module TestRecords
def create_member_user! def create_member_user!
User.create!(name: 'spec user', User.create!(name: 'spec user',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'member',
banned_at: nil)
role: 'member')
end end


def create_admin_user! def create_admin_user!
User.create!(name: 'spec admin', User.create!(name: 'spec admin',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'admin',
banned_at: nil)
role: 'admin')
end end
end end

+ 2
- 0
frontend/src/App.tsx View File

@@ -10,6 +10,7 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialListPage from '@/pages/materials/MaterialListPage'
@@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/posts/changes" element={<PostHistoryPage/>}/> <Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/> <Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/> <Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/> <Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage/>}/>


+ 32
- 14
frontend/src/components/TagLink.tsx View File

@@ -45,9 +45,9 @@ export default (({ tag,
<> <>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki) && (
<span className="mr-1"> <span className="mr-1">
{(tag.materialId != null || tag.hasWiki)
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? ( ? (
tag.materialId == null
tag.materialId == null && !(tag.hasDeerjikists)
? ( ? (
<PrefetchLink <PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`} to={`/wiki/${ encodeURIComponent (tag.name) }`}
@@ -55,11 +55,19 @@ export default (({ tag,
? ?
</PrefetchLink>) </PrefetchLink>)
: ( : (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
?
</PrefetchLink>))
tag.materialId != null
? (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className={linkClass}>
?
</PrefetchLink>)))
: ( : (
['character', 'material'].includes (tag.category) ['character', 'material'].includes (tag.category)
? ( ? (
@@ -71,13 +79,23 @@ export default (({ tag,
! !
</PrefetchLink>) </PrefetchLink>)
: ( : (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>))}
tag.category === 'deerjikist'
? (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } に関する情報が存在しません.`}>
!
</PrefetchLink>)
: (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>)))}
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span


+ 6
- 1
frontend/src/consts.ts View File

@@ -1,4 +1,4 @@
import type { Category } from 'types'
import type { Category, Platform } from 'types'


export const LIGHT_COLOUR_SHADE = 800 export const LIGHT_COLOUR_SHADE = 800
export const DARK_COLOUR_SHADE = 300 export const DARK_COLOUR_SHADE = 300
@@ -31,6 +31,11 @@ export const FETCH_POSTS_ORDER_FIELDS = [
'updated_at', 'updated_at',
] as const ] as const


export const PLATFORMS = ['nico', 'youtube'] as const

export const PLATFORM_NAMES: Record<Platform, string> =
{ nico: 'ニコニコ', youtube: 'YouTube' } as const

export const TAG_COLOUR = { export const TAG_COLOUR = {
deerjikist: 'rose', deerjikist: 'rose',
meme: 'purple', meme: 'purple',


+ 6
- 5
frontend/src/lib/queryKeys.ts View File

@@ -9,11 +9,12 @@ export const postsKeys = {
['posts', 'changes', p] as const } ['posts', 'changes', p] as const }


export const tagsKeys = { export const tagsKeys = {
root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
show: (name: string) => ['tags', name] as const,
changes: (p: { id?: string; page: number; limit: number }) =>
['tags', 'changes', p] as const }
root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
show: (name: string) => ['tags', name] as const,
changes: (p: { id?: string; page: number; limit: number }) =>
['tags', 'changes', p] as const,
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }


export const wikiKeys = { export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,


+ 7
- 1
frontend/src/lib/tags.ts View File

@@ -1,6 +1,6 @@
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'


import type { FetchTagsParams, Tag, TagVersion } from '@/types'
import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types'




export const fetchTags = async ( export const fetchTags = async (
@@ -56,3 +56,9 @@ export const fetchTagChanges = async (
versions: TagVersion[] versions: TagVersion[]
count: number }> => count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } })


export const fetchDeerjikistsByTag = async (
id: string,
): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> =>
await apiGet (`/tags/${ id }/deerjikists`)

+ 155
- 0
frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx View File

@@ -0,0 +1,155 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'

import TagLink from '@/components/TagLink'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { PLATFORM_NAMES, PLATFORMS } from '@/consts'
import { apiPut } from '@/lib/api'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchDeerjikistsByTag } from '@/lib/tags'
import { cn } from '@/lib/utils'

import type { FC, FormEvent } from 'react'

import type { Deerjikist, Platform } from '@/types'


export default (() => {
const { id } = useParams ()
const tagId = String (id ?? '')
const tagKey = tagsKeys.deerjikists (tagId)

const { data: qData, isLoading: loading } =
useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) })
const tag = qData?.tag
const deerjikists = qData?.deerjikists ?? []

const [data, setData] =
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([])
const [disabled, setDisabled] = useState (true)

const qc = useQueryClient ()

const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()

try
{
setDisabled (true)

setData (await apiPut<Deerjikist[]> (`/tags/${ id }/deerjikists`, data))
qc.invalidateQueries ({ queryKey: tagsKeys.root })

toast ({ description: '更新しました.' })
}
catch
{
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
}
finally
{
setDisabled (false)
}
}

useEffect (() => {
if (!(tag))
{
setDisabled (true)
return
}

setData (deerjikists)
setDisabled (false)
}, [tag, deerjikists])

return (
<MainArea>
{(loading || !(tag)) ? 'Loading...' : (
<div className="max-w-xl">
<PageTitle>
<TagLink tag={tag} withWiki={false} withCount={false}/>
</PageTitle>

<form onSubmit={handleSubmit} className="my-4 space-y-2">
{data.map ((datum, i) => (
<fieldset key={i} className="min-w-0 rounded-lg border border-gray-300
dark:border-gray-700 p-4">
<legend className="px-2 text-sm font-semibold text-gray-700
dark:text-gray-300">
<button
type="button"
disabled={disabled}
onClick={() => setData (prev => [...prev.slice (0, i),
...prev.slice (i + 1)])}>
#{i + 1}
</button>
</legend>

{/* プラットフォーム */}
<div>
<Label>プラットフォーム</Label>
<select
className="w-full border p-2 rounded"
disabled={disabled}
value={datum.platform ?? ''}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i],
platform: (e.target.value || null) as Platform | null }
return rtn
})}>
<option value="">&nbsp;</option>
{PLATFORMS.map (p => (
<option key={p} value={p}>
{PLATFORM_NAMES[p]}
</option>))}
</select>
</div>

{/* コード */}
<div>
<Label>コード</Label>
<input
type="text"
disabled={disabled}
className="w-full border p-2 rounded"
value={datum.code}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i], code: e.target.value }
return rtn
})}/>
</div>
</fieldset>
))}

<div className="py-3">
<button
type="button"
disabled={disabled}
onClick={() => setData (prev => [...prev, { platform: null, code: '' }])}>
+
</button>
</div>

<div className="py-3">
<button
type="submit"
disabled={disabled}
className={cn ('px-4 py-2 rounded',
(disabled
? 'text-gray-300 bg-gray-500'
: 'text-white bg-blue-500'))}>
更新
</button>
</div>
</form>
</div>
)}
</MainArea>)
}) satisfies FC

+ 7
- 1
frontend/src/types.ts View File

@@ -1,5 +1,6 @@
import { CATEGORIES, import { CATEGORIES,
FETCH_POSTS_ORDER_FIELDS, FETCH_POSTS_ORDER_FIELDS,
PLATFORMS,
USER_ROLES, USER_ROLES,
ViewFlagBehavior } from '@/consts' ViewFlagBehavior } from '@/consts'


@@ -7,6 +8,8 @@ import type { ReactNode } from 'react'


export type Category = typeof CATEGORIES[number] export type Category = typeof CATEGORIES[number]


export type Deerjikist = { platform: Platform; code: string }

export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }` export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }`


export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number] export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number]
@@ -114,6 +117,8 @@ export type NiconicoViewerHandle = {
showComments: () => void showComments: () => void
hideComments: () => void } hideComments: () => void }


export type Platform = typeof PLATFORMS[number]

export type Post = { export type Post = {
id: number id: number
url: string url: string
@@ -178,7 +183,8 @@ export type Tag = {
createdAt: string createdAt: string
updatedAt: string updatedAt: string
hasWiki: boolean hasWiki: boolean
materialId: number
materialId: number | null
hasDeerjikists: boolean
children?: Tag[] children?: Tag[]
matchedAlias?: string | null } matchedAlias?: string | null }




Loading…
Cancel
Save