| @@ -50,8 +50,6 @@ group :development, :test do | |||
| gem 'factory_bot_rails' | |||
| end | |||
| gem "mysql2", "~> 0.5.6" | |||
| gem "image_processing", "~> 1.14" | |||
| @@ -69,3 +67,5 @@ gem 'whenever', require: false | |||
| gem 'discard' | |||
| gem "rspec-rails", "~> 8.0", :groups => [:development, :test] | |||
| gem 'aws-sdk-s3', require: false | |||
| @@ -73,6 +73,25 @@ GEM | |||
| tzinfo (~> 2.0, >= 2.0.5) | |||
| uri (>= 0.13.1) | |||
| ast (2.4.3) | |||
| aws-eventstream (1.4.0) | |||
| aws-partitions (1.1231.0) | |||
| aws-sdk-core (3.244.0) | |||
| aws-eventstream (~> 1, >= 1.3.0) | |||
| aws-partitions (~> 1, >= 1.992.0) | |||
| aws-sigv4 (~> 1.9) | |||
| base64 | |||
| bigdecimal | |||
| jmespath (~> 1, >= 1.6.1) | |||
| logger | |||
| aws-sdk-kms (1.123.0) | |||
| aws-sdk-core (~> 3, >= 3.244.0) | |||
| aws-sigv4 (~> 1.5) | |||
| aws-sdk-s3 (1.217.0) | |||
| aws-sdk-core (~> 3, >= 3.244.0) | |||
| aws-sdk-kms (~> 1) | |||
| aws-sigv4 (~> 1.5) | |||
| aws-sigv4 (1.12.1) | |||
| aws-eventstream (~> 1, >= 1.0.2) | |||
| base64 (0.2.0) | |||
| bcrypt_pbkdf (1.1.1) | |||
| bcrypt_pbkdf (1.1.1-arm64-darwin) | |||
| @@ -157,6 +176,7 @@ GEM | |||
| pp (>= 0.6.0) | |||
| rdoc (>= 4.0.0) | |||
| reline (>= 0.4.2) | |||
| jmespath (1.6.2) | |||
| json (2.12.0) | |||
| jwt (2.10.1) | |||
| base64 | |||
| @@ -441,6 +461,7 @@ PLATFORMS | |||
| x86_64-linux-musl | |||
| DEPENDENCIES | |||
| aws-sdk-s3 | |||
| bootsnap | |||
| brakeman | |||
| diff-lcs | |||
| @@ -0,0 +1,94 @@ | |||
| class MaterialsController < ApplicationController | |||
| def index | |||
| page = (params[:page].presence || 1).to_i | |||
| limit = (params[:limit].presence || 20).to_i | |||
| page = 1 if page < 1 | |||
| limit = 1 if limit < 1 | |||
| offset = (page - 1) * limit | |||
| tag_id = params[:tag_id].presence | |||
| parent_id = params[:parent_id].presence | |||
| q = Material.includes(:tag, :created_by_user).with_attached_file | |||
| q = q.where(tag_id:) if tag_id | |||
| q = q.where(parent_id:) if parent_id | |||
| count = q.count | |||
| materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) | |||
| render json: { materials: materials.map { |m| material_json(m) }, count: count } | |||
| end | |||
| def show | |||
| material = Material.includes(:tag, :created_by_user).with_attached_file.find_by(id: params[:id]) | |||
| return head :not_found unless material | |||
| render json: material_json(material) | |||
| end | |||
| def create | |||
| return head :unauthorized unless current_user | |||
| return head :forbidden unless current_user.gte_member? | |||
| file = params[:file] | |||
| return head :bad_request if file.blank? | |||
| material = Material.new( | |||
| url: params[:url].presence, | |||
| parent_id: params[:parent_id].presence, | |||
| tag_id: params[:tag_id].presence, | |||
| created_by_user: current_user) | |||
| material.file.attach(file) | |||
| if material.save | |||
| render json: material_json(material), status: :created | |||
| else | |||
| render json: { errors: material.errors.full_messages }, status: :unprocessable_entity | |||
| end | |||
| end | |||
| def update | |||
| return head :unauthorized unless current_user | |||
| return head :forbidden unless current_user.gte_member? | |||
| material = Material.with_attached_file.find_by(id: params[:id]) | |||
| return head :not_found unless material | |||
| material.assign_attributes( | |||
| url: params[:url].presence, | |||
| parent_id: params[:parent_id].presence, | |||
| tag_id: params[:tag_id].presence | |||
| ) | |||
| material.file.attach(params[:file]) if params[:file].present? | |||
| if material.save | |||
| render json: material_json(material) | |||
| else | |||
| render json: { errors: material.errors.full_messages }, status: :unprocessable_entity | |||
| end | |||
| end | |||
| def destroy | |||
| return head :unauthorized unless current_user | |||
| return head :forbidden unless current_user.gte_member? | |||
| material = Material.find_by(id: params[:id]) | |||
| return head :not_found unless material | |||
| material.discard | |||
| head :no_content | |||
| end | |||
| private | |||
| def material_json(material) | |||
| MaterialRepr.base(material).merge( | |||
| 'filename' => material.file.attached? ? material.file.filename.to_s : nil, | |||
| 'byte_size' => material.file.attached? ? material.file.byte_size : nil, | |||
| 'content_type' => material.file.attached? ? material.file.content_type : nil, | |||
| 'url' => material.file.attached? ? url_for(material.file) : nil | |||
| ) | |||
| end | |||
| end | |||
| @@ -0,0 +1,31 @@ | |||
| class Material < ApplicationRecord | |||
| include MyDiscard | |||
| default_scope -> { kept } | |||
| belongs_to :parent, class_name: 'Material', optional: true | |||
| has_many :children, class_name: 'Material', foreign_key: :parent_id, dependent: :nullify | |||
| belongs_to :tag, optional: true | |||
| belongs_to :created_by_user, class_name: 'User', optional: true | |||
| belongs_to :updated_by_user, class_name: 'User', optional: true | |||
| has_one_attached :file, dependent: :purge | |||
| validate :file_must_be_attached | |||
| validate :tag_must_be_material_category | |||
| private | |||
| def file_must_be_attached | |||
| return if url.present? || file.attached? | |||
| errors.add(:url, 'URL かファイルのどちらかは必須です.') | |||
| end | |||
| def tag_must_be_material_category | |||
| return if tag.blank? || tag.material? | |||
| errors.add(:tag, '素材カテゴリのタグを指定してください.') | |||
| end | |||
| end | |||
| @@ -0,0 +1,17 @@ | |||
| # frozen_string_literal: true | |||
| module MaterialRepr | |||
| BASE = { only: [:id, :url, :parent_id, :created_at, :updated_at], | |||
| include: { created_by_user: UserRepr::BASE, tag: TagRepr::BASE } }.freeze | |||
| module_function | |||
| def base(material) | |||
| material.as_json(BASE) | |||
| end | |||
| def many(materials) | |||
| materials.map { |m| base(m) } | |||
| end | |||
| end | |||
| @@ -19,7 +19,7 @@ Rails.application.configure do | |||
| # config.asset_host = "http://assets.example.com" | |||
| # Store uploaded files on the local file system (see config/storage.yml for options). | |||
| config.active_storage.service = :local | |||
| config.active_storage.service = :r2 | |||
| # Assume all access to the app is happening through a SSL-terminating reverse proxy. | |||
| config.assume_ssl = true | |||
| @@ -81,4 +81,6 @@ Rails.application.routes.draw do | |||
| resources :comments, controller: :theatre_comments, only: [:index, :create] | |||
| end | |||
| resources :materials, only: [:index, :show, :create, :update, :destroy] | |||
| end | |||
| @@ -6,29 +6,10 @@ local: | |||
| service: Disk | |||
| root: <%= Rails.root.join("storage") %> | |||
| # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) | |||
| # amazon: | |||
| # service: S3 | |||
| # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> | |||
| # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> | |||
| # region: us-east-1 | |||
| # bucket: your_own_bucket-<%= Rails.env %> | |||
| # Remember not to checkin your GCS keyfile to a repository | |||
| # google: | |||
| # service: GCS | |||
| # project: your_project | |||
| # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> | |||
| # bucket: your_own_bucket-<%= Rails.env %> | |||
| # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) | |||
| # microsoft: | |||
| # service: AzureStorage | |||
| # storage_account_name: your_account_name | |||
| # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> | |||
| # container: your_container_name-<%= Rails.env %> | |||
| # mirror: | |||
| # service: Mirror | |||
| # primary: local | |||
| # mirrors: [ amazon, google, microsoft ] | |||
| r2: | |||
| service: S3 | |||
| endpoint: <%= ENV['R2_ENDPOINT'] %> | |||
| access_key_id: <%= ENV['R2_ACCESS_KEY_ID'] %> | |||
| secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %> | |||
| bucket: <%= ENV['R2_BUCKET'] %> | |||
| region: auto | |||
| @@ -0,0 +1,34 @@ | |||
| class CreateMaterials < ActiveRecord::Migration[8.0] | |||
| def change | |||
| create_table :materials do |t| | |||
| t.string :url | |||
| t.references :parent, index: true, foreign_key: { to_table: :materials } | |||
| t.references :tag, index: true, foreign_key: true | |||
| t.references :created_by_user, foreign_key: { to_table: :users } | |||
| t.references :updated_by_user, foreign_key: { to_table: :users } | |||
| t.timestamps | |||
| t.datetime :discarded_at, index: true | |||
| t.virtual :active_url, type: :string, | |||
| as: 'IF(discarded_at IS NULL, url, NULL)', | |||
| stored: false | |||
| t.index :active_url, unique: true | |||
| end | |||
| create_table :material_versions do |t| | |||
| t.references :material, null: false, foreign_key: true | |||
| t.integer :version_no, null: false | |||
| t.string :url, index: true | |||
| t.references :parent, index: true, foreign_key: { to_table: :materials } | |||
| t.references :tag, index: true, foreign_key: true | |||
| t.references :created_by_user, foreign_key: { to_table: :users } | |||
| t.references :updated_by_user, foreign_key: { to_table: :users } | |||
| t.timestamps | |||
| t.datetime :discarded_at, index: true | |||
| t.index [:material_id, :version_no], | |||
| unique: true, | |||
| name: 'index_material_versions_on_material_id_and_version_no' | |||
| end | |||
| end | |||
| end | |||
| @@ -10,7 +10,7 @@ | |||
| # | |||
| # It's strongly recommended that you check this file into your version control system. | |||
| ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do | |||
| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "name", null: false | |||
| t.string "record_type", null: false | |||
| @@ -56,6 +56,45 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true | |||
| end | |||
| create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "material_id", null: false | |||
| t.integer "version_no", null: false | |||
| t.string "url" | |||
| t.bigint "parent_id" | |||
| t.bigint "tag_id" | |||
| t.bigint "created_by_user_id" | |||
| t.bigint "updated_by_user_id" | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.datetime "discarded_at" | |||
| t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id" | |||
| t.index ["discarded_at"], name: "index_material_versions_on_discarded_at" | |||
| t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true | |||
| t.index ["material_id"], name: "index_material_versions_on_material_id" | |||
| t.index ["parent_id"], name: "index_material_versions_on_parent_id" | |||
| t.index ["tag_id"], name: "index_material_versions_on_tag_id" | |||
| t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id" | |||
| t.index ["url"], name: "index_material_versions_on_url" | |||
| end | |||
| create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "url" | |||
| t.bigint "parent_id" | |||
| t.bigint "tag_id" | |||
| t.bigint "created_by_user_id" | |||
| t.bigint "updated_by_user_id" | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.datetime "discarded_at" | |||
| t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)" | |||
| t.index ["active_url"], name: "index_materials_on_active_url", unique: true | |||
| t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id" | |||
| t.index ["discarded_at"], name: "index_materials_on_discarded_at" | |||
| t.index ["parent_id"], name: "index_materials_on_parent_id" | |||
| t.index ["tag_id"], name: "index_materials_on_tag_id" | |||
| t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id" | |||
| end | |||
| create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "nico_tag_id", null: false | |||
| t.bigint "tag_id", null: false | |||
| @@ -239,6 +278,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| t.datetime "updated_at", null: false | |||
| 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 | |||
| @@ -254,6 +306,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) 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.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 | |||
| @@ -292,6 +345,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" | |||
| add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" | |||
| add_foreign_key "material_versions", "materials" | |||
| add_foreign_key "material_versions", "materials", column: "parent_id" | |||
| add_foreign_key "material_versions", "tags" | |||
| add_foreign_key "material_versions", "users", column: "created_by_user_id" | |||
| add_foreign_key "material_versions", "users", column: "updated_by_user_id" | |||
| add_foreign_key "materials", "materials", column: "parent_id" | |||
| add_foreign_key "materials", "tags" | |||
| add_foreign_key "materials", "users", column: "created_by_user_id" | |||
| add_foreign_key "materials", "users", column: "updated_by_user_id" | |||
| add_foreign_key "nico_tag_relations", "tags" | |||
| add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" | |||
| add_foreign_key "post_similarities", "posts" | |||
| @@ -320,6 +382,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) 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" | |||