| @@ -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 | ||||
| @@ -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,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) | ||||
| @@ -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 | ||||
| @@ -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) | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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,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 | ||||
| @@ -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/>}/> | ||||
| @@ -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 | ||||
| @@ -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', | ||||
| @@ -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, | ||||
| @@ -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`) | |||||
| @@ -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=""> </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 | |||||
| @@ -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 } | ||||