diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 0d412e0..4f3d6ed 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -1,14 +1,16 @@ class ApplicationController < ActionController::API + before_action :reject_banned_ip_address! before_action :authenticate_user + before_action :reject_banned_user! - def current_user - @current_user - end + def current_user = @current_user private def authenticate_user code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE'] + return if code.blank? + @current_user = User.find_by(inheritance_code: code) end @@ -22,4 +24,17 @@ class ApplicationController < ActionController::API s.in?(['', '1', 'true', 'on', 'yes']) 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 diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9b8bbe9..aed029f 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -1,3 +1,7 @@ +require 'net/http' +require 'uri' + + class TagsController < ApplicationController def index post_id = params[:post] @@ -182,7 +186,8 @@ class TagsController < ApplicationController .find_by(id: params[:id]) 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 deerjikists_by_name @@ -194,7 +199,31 @@ class TagsController < ApplicationController .find_by(tag_names: { name: }) 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 def materials_by_name @@ -391,4 +420,21 @@ class TagsController < ApplicationController TagImplication.create!(tag:, parent_tag:) 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[ + /), + ) + 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 diff --git a/backend/spec/requests/users_spec.rb b/backend/spec/requests/users_spec.rb index 1f28e95..67c556f 100644 --- a/backend/spec/requests/users_spec.rb +++ b/backend/spec/requests/users_spec.rb @@ -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(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 - 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) 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 - 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) 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) 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(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 - 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 - 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(json["valid"]).to eq(false) + expect(json['valid']).to eq(false) 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 { - post "/users/verify", params: { code: user.inheritance_code } + post '/users/verify', params: { code: user.inheritance_code } }.to change(UserIp, :count).by(1) + .and change(IpAddress, :count).by(1) 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 - 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 { - post "/users/verify", params: { code: user.inheritance_code } + post '/users/verify', params: { code: user.inheritance_code } }.not_to change(UserIp, :count) 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 - 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) 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(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 diff --git a/backend/spec/support/test_records.rb b/backend/spec/support/test_records.rb index 0e4796e..b6a7cd5 100644 --- a/backend/spec/support/test_records.rb +++ b/backend/spec/support/test_records.rb @@ -2,14 +2,12 @@ module TestRecords def create_member_user! User.create!(name: 'spec user', inheritance_code: SecureRandom.hex(16), - role: 'member', - banned_at: nil) + role: 'member') end def create_admin_user! User.create!(name: 'spec admin', inheritance_code: SecureRandom.hex(16), - role: 'admin', - banned_at: nil) + role: 'admin') end end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f44d7d..f52209d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' +import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialListPage from '@/pages/materials/MaterialListPage' @@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 884c851..a68f8a9 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -45,9 +45,9 @@ export default (({ tag, <> {(linkFlg && withWiki) && ( - {(tag.materialId != null || tag.hasWiki) + {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) ? ( - tag.materialId == null + tag.materialId == null && !(tag.hasDeerjikists) ? ( ) : ( - - ? - )) + tag.materialId != null + ? ( + + ? + ) + : ( + + ? + ))) : ( ['character', 'material'].includes (tag.category) ? ( @@ -71,13 +79,23 @@ export default (({ tag, ! ) : ( - - ! - ))} + tag.category === 'deerjikist' + ? ( + + ! + ) + : ( + + ! + )))} )} {nestLevel > 0 && ( = + { nico: 'ニコニコ', youtube: 'YouTube' } as const + export const TAG_COLOUR = { deerjikist: 'rose', meme: 'purple', diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 97bae56..6ac3f21 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -9,11 +9,12 @@ export const postsKeys = { ['posts', 'changes', p] as const } 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 = { root: ['wiki'] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index e2c95c3..74eba45 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,6 +1,6 @@ import { apiGet } from '@/lib/api' -import type { FetchTagsParams, Tag, TagVersion } from '@/types' +import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types' export const fetchTags = async ( @@ -56,3 +56,9 @@ export const fetchTagChanges = async ( versions: TagVersion[] count: number }> => 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`) diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx new file mode 100644 index 0000000..ee3651f --- /dev/null +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx @@ -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 & { platform: Platform | null })[]> ([]) + const [disabled, setDisabled] = useState (true) + + const qc = useQueryClient () + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault () + + try + { + setDisabled (true) + + setData (await apiPut (`/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 ( + + {(loading || !(tag)) ? 'Loading...' : ( + + + + + + + {data.map ((datum, i) => ( + + + setData (prev => [...prev.slice (0, i), + ...prev.slice (i + 1)])}> + #{i + 1} + + + + {/* プラットフォーム */} + + プラットフォーム + setData (prev => { + const rtn = [...prev] + rtn[i] = { ...rtn[i], + platform: (e.target.value || null) as Platform | null } + return rtn + })}> + + {PLATFORMS.map (p => ( + + {PLATFORM_NAMES[p]} + ))} + + + + {/* コード */} + + コード + setData (prev => { + const rtn = [...prev] + rtn[i] = { ...rtn[i], code: e.target.value } + return rtn + })}/> + + + ))} + + + setData (prev => [...prev, { platform: null, code: '' }])}> + + + + + + + + 更新 + + + + + )} + ) +}) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2de3d08..12f9a55 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,5 +1,6 @@ import { CATEGORIES, FETCH_POSTS_ORDER_FIELDS, + PLATFORMS, USER_ROLES, ViewFlagBehavior } from '@/consts' @@ -7,6 +8,8 @@ import type { ReactNode } from 'react' export type Category = typeof CATEGORIES[number] +export type Deerjikist = { platform: Platform; code: string } + export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }` export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number] @@ -114,6 +117,8 @@ export type NiconicoViewerHandle = { showComments: () => void hideComments: () => void } +export type Platform = typeof PLATFORMS[number] + export type Post = { id: number url: string @@ -178,7 +183,8 @@ export type Tag = { createdAt: string updatedAt: string hasWiki: boolean - materialId: number + materialId: number | null + hasDeerjikists: boolean children?: Tag[] matchedAlias?: string | null }