コミットを比較

...

3 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 41a98ff725 #306 2026-06-27 05:37:58 +09:00
みてるぞ c836369dfc #306 2026-06-27 05:30:23 +09:00
みてるぞ 363146c219 #306 2026-06-27 05:20:25 +09:00
13個のファイルの変更306行の追加22行の削除
+1 -4
ファイルの表示
@@ -523,10 +523,7 @@ class MaterialsController < ApplicationController
content_type: file.content_type,
)
if file_sha256.present?
blob.metadata['sha256'] = file_sha256
blob.save! if blob.changed?
end
MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256)
blob
ensure
+4 -1
ファイルの表示
@@ -1,6 +1,9 @@
class Material < ApplicationRecord
include MyDiscard
SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file',
'legacy_drive_path'].freeze
default_scope -> { kept }
belongs_to :parent, class_name: 'Material', optional: true
@@ -20,7 +23,7 @@ class Material < ApplicationRecord
validates :tag_id, uniqueness: true, allow_nil: true
validates :source_kind,
inclusion: { in: MaterialSyncSuppression::SOURCE_KINDS },
inclusion: { in: SOURCE_KINDS },
allow_blank: true
validate :file_must_be_attached
+26 -3
ファイルの表示
@@ -1,16 +1,39 @@
require 'digest'
require 'json'
class MaterialFileSha256
def self.blob_metadata blob
metadata = blob.metadata
return metadata if metadata.is_a?(Hash)
return { } if metadata.blank?
JSON.parse(metadata)
rescue JSON::ParserError
{ }
end
def self.metadata_sha256 blob
blob_metadata(blob)['sha256'].presence
end
def self.assign_metadata_sha256! blob, sha256
return if sha256.blank?
metadata = blob_metadata(blob)
metadata['sha256'] = sha256
blob.metadata = metadata
blob.save! if blob.changed?
end
def self.from_blob blob, allow_download: false
sha256 = blob.metadata['sha256']
sha256 = metadata_sha256(blob)
return sha256 if sha256.present?
return nil unless allow_download
begin
blob.open do |file|
sha256 = Digest::SHA256.file(file.path).hexdigest
blob.metadata['sha256'] = sha256
blob.save! if blob.changed?
assign_metadata_sha256!(blob, sha256)
sha256
end
rescue ActiveStorage::FileNotFoundError, ArgumentError => error
+2 -3
ファイルの表示
@@ -148,8 +148,7 @@ class MaterialSyncImporter
io: tempfile,
filename: @attributes[:filename],
content_type: @attributes[:content_type])
blob.metadata['sha256'] = file_sha256 if file_sha256.present?
blob.save! if blob.changed?
MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256)
tempfile.rewind
blob
end
@@ -226,7 +225,7 @@ class MaterialSyncImporter
if expected_sha256.present?
return false unless material.file.attached?
return material.file.blob.metadata['sha256'] == expected_sha256
return MaterialFileSha256.metadata_sha256(material.file.blob) == expected_sha256
end
!material.file.attached?
+1 -1
ファイルの表示
@@ -57,7 +57,7 @@ class MaterialVersionRecorder < VersionRecorder
file_content_type: blob.content_type,
file_byte_size: blob.byte_size,
file_checksum: blob.checksum,
file_sha256: blob.metadata['sha256'] }
file_sha256: MaterialFileSha256.metadata_sha256(blob) }
end
def empty_file_snapshot
生成ファイル
+35 -8
ファイルの表示
@@ -72,6 +72,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
t.index ["user_id"], name: "index_gekanator_games_on_user_id"
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
end
create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -274,9 +275,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at"
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -285,13 +287,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "target_post_id", null: false
t.float "cos", null: false
t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos"
t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc }
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end
@@ -331,6 +334,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -345,6 +350,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.integer "version_no", null: false
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -391,7 +397,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.bigint "tag_id", null: false
t.bigint "target_tag_id", null: false
t.float "cos", null: false
t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos"
t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc }
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
end
@@ -401,30 +407,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.string "event_type", null: false
t.string "name", null: false
t.string "category", null: false
t.datetime "deprecated_at"
t.text "aliases", null: false
t.text "parent_tag_ids", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at"
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false
t.datetime "deprecated_at"
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -549,6 +557,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
@@ -565,11 +586,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -614,6 +637,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
@@ -692,6 +717,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
add_foreign_key "user_ips", "users"
add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
+19
ファイルの表示
@@ -0,0 +1,19 @@
require 'rails_helper'
RSpec.describe Material, type: :model do
let(:user) { create(:user, :member) }
it 'allows only material source kinds, not suppression prefix kinds' do
material = described_class.new(url: 'https://example.com/material',
source_kind: 'google_drive_path_prefix',
source_path: '素材/危険',
created_by_user: user,
updated_by_user: user)
expect(material).not_to be_valid
expect(material.errors[:source_kind]).to be_present
material.source_kind = 'google_drive_path'
expect(material).to be_valid
end
end
+62
ファイルの表示
@@ -113,6 +113,68 @@ RSpec.describe 'Materials API', type: :request do
expect(response_materials.size).to eq(1)
expect(response_materials.first['id']).to eq(material_b.id)
end
it 'filters by descendant tags and returns stable parent tag groups' do
root =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_root'),
category: :material)
child_b =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'),
category: :material)
child_a =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'),
category: :material)
deprecated =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'),
category: :material,
deprecated_at: Time.current)
grandchild =
Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'),
category: :material)
root_material =
build_material(tag: root, user: member_user,
file: dummy_upload(filename: 'root.png'))
child_a_material =
build_material(tag: child_a, user: member_user,
file: dummy_upload(filename: 'child_a.png'))
grandchild_material =
build_material(tag: grandchild, user: member_user,
file: dummy_upload(filename: 'grandchild.png'))
TagImplication.create!(parent_tag: root, tag: child_b)
TagImplication.create!(parent_tag: root, tag: child_a)
TagImplication.create!(parent_tag: child_b, tag: deprecated)
TagImplication.create!(parent_tag: deprecated, tag: grandchild)
get '/materials',
params: { tag_id: root.id, include_descendants: '1',
group_by: 'parent_tag', sort: 'id', direction: 'asc' }
expect(response).to have_http_status(:ok)
expect(response_materials.map { |m| m['id'] })
.to include(root_material.id, child_a_material.id, grandchild_material.id)
expect(json['tag_scope']).to eq(
'tag' => {
'id' => root.id,
'name' => 'material_scope_root',
'category' => 'material'
},
'include_descendants' => true
)
expect(json['groups'].map { |group| group.dig('tag', 'name') })
.to eq(['material_scope_a', 'material_scope_b', 'material_scope_root'])
expect(json['groups'].find { |group| group.dig('tag', 'id') == child_b.id }
.fetch('material_ids')).to eq([grandchild_material.id])
end
it 'returns nil tag_scope when tag_id is unknown' do
get '/materials', params: { tag_id: 999_999, group_by: 'parent_tag' }
expect(response).to have_http_status(:ok)
expect(json['tag_scope']).to be_nil
expect(json['groups']).to eq([])
expect(response_materials).to eq([])
end
end
describe 'GET /materials/:id' do
+23
ファイルの表示
@@ -0,0 +1,23 @@
require 'rails_helper'
RSpec.describe MaterialFileSha256 do
describe '.metadata_sha256' do
it 'reads sha256 from Hash metadata' do
blob = instance_double(ActiveStorage::Blob, metadata: { 'sha256' => 'abc123' })
expect(described_class.metadata_sha256(blob)).to eq('abc123')
end
it 'reads sha256 from JSON string metadata' do
blob = instance_double(ActiveStorage::Blob, metadata: '{"sha256":"abc123"}')
expect(described_class.metadata_sha256(blob)).to eq('abc123')
end
it 'returns nil for invalid metadata' do
blob = instance_double(ActiveStorage::Blob, metadata: '{')
expect(described_class.metadata_sha256(blob)).to be_nil
end
end
end
+66
ファイルの表示
@@ -126,4 +126,70 @@ describe ('MaterialListPage', () => {
expect (screen.getByText ('サイズ:')).toBeInTheDocument ()
expect (screen.getByText ('2.0 KB')).toBeInTheDocument ()
})
it ('shows the selected tag scope and tag-specific add links', async () => {
api.apiGet.mockResolvedValueOnce ({
materials: [
buildMaterial ({
id: 10,
tag: buildTag ({ id: 30, name: '泣き', category: 'material' }),
}),
],
count: 1,
tagScope: {
tag: { id: 20, name: '伊地知ニジカ', category: 'material' },
includeDescendants: true,
},
groups: [
{
key: 'tag:30',
tag: { id: 30, name: '泣き', category: 'material' },
materialIds: [10],
count: 1,
},
],
})
renderWithProviders (
<MaterialListPage/>,
{ route: '/materials?tag_id=20&include_descendants=1&group_by=parent_tag' },
)
expect (await screen.findByText (
(_, element) => element?.textContent === '伊地知ニジカ 配下の素材を表示中',
)).toBeInTheDocument ()
const addLinks = screen.getAllByRole ('link', { name: 'このタグに素材を追加' })
expect (addLinks[0]).toHaveAttribute (
'href',
'/materials/new?tag=%E4%BC%8A%E5%9C%B0%E7%9F%A5%E3%83%8B%E3%82%B8%E3%82%AB'
+ '&return_to=%2Fmaterials%3Ftag_id%3D20%26include_descendants%3D1%26group_by%3Dparent_tag',
)
expect (addLinks[1]).toHaveAttribute (
'href',
'/materials/new?tag=%E6%B3%A3%E3%81%8D'
+ '&return_to=%2Fmaterials%3Ftag_id%3D20%26include_descendants%3D1%26group_by%3Dparent_tag',
)
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
)
})
it ('shows a clear link when tag_id does not resolve to tag_scope', async () => {
api.apiGet.mockResolvedValueOnce ({
materials: [],
count: 0,
tagScope: null,
groups: [],
})
renderWithProviders (<MaterialListPage/>, { route: '/materials?tag_id=999' })
expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
)
})
})
+13 -1
ファイルの表示
@@ -106,7 +106,7 @@ const clearedTagSelectionPath = (
const qs = new URLSearchParams (locationSearch)
qs.delete ('tag_id')
qs.delete ('include_descendants')
qs.set ('group_by', 'none')
qs.delete ('group_by')
qs.delete ('page')
qs.set ('material_filter', materialFilter)
return `/materials?${ qs.toString () }`
@@ -471,6 +471,18 @@ const MaterialListPage: FC = () => {
</PrefetchLink>
</div>)}
{(tagId != null && tagScope == null) && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border
border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900
dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<span></span>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-amber-800 dark:text-amber-200">
</PrefetchLink>
</div>)}
{tagState === 'untagged' && (
<PrefetchLink
+52
ファイルの表示
@@ -1,5 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Route, Routes, useLocation } from 'react-router-dom'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render'
@@ -17,6 +18,11 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
const LocationView = () => {
const location = useLocation ()
return <div>{location.pathname + location.search}</div>
}
describe ('MaterialNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
@@ -69,4 +75,50 @@ describe ('MaterialNewPage', () => {
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true')
})
it ('returns only to safe material URLs after successful creation', async () => {
api.apiPost.mockResolvedValueOnce ({})
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=%2Fmaterials%3Ftag_id%3D20',
},
)
fireEvent.change (screen.getAllByRole ('textbox')[1], {
target: { value: 'https://example.com/ref' },
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials?tag_id=20')).toBeInTheDocument ()
})
it ('ignores unsafe return_to values after successful creation', async () => {
api.apiPost.mockResolvedValueOnce ({})
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=https%3A%2F%2Fexample.com%2Fevil',
},
)
fireEvent.change (screen.getAllByRole ('textbox')[1], {
target: { value: 'https://example.com/ref' },
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials')).toBeInTheDocument ()
})
})
+2 -1
ファイルの表示
@@ -28,6 +28,7 @@ const MaterialNewPage: FC = () => {
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const returnToQuery = query.get ('return_to') ?? ''
const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : ''
const navigate = useNavigate ()
@@ -55,7 +56,7 @@ const MaterialNewPage: FC = () => {
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
toast ({ title: '送信成功!' })
navigate (returnToQuery || `/materials?tag=${ encodeURIComponent (tag) }`)
navigate (safeReturnTo || '/materials')
},
onError: error => {
applyValidationError (error)