| @@ -50,8 +50,6 @@ group :development, :test do | |||||
| gem 'factory_bot_rails' | gem 'factory_bot_rails' | ||||
| end | end | ||||
| gem "mysql2", "~> 0.5.6" | gem "mysql2", "~> 0.5.6" | ||||
| gem "image_processing", "~> 1.14" | gem "image_processing", "~> 1.14" | ||||
| @@ -69,3 +67,5 @@ gem 'whenever', require: false | |||||
| gem 'discard' | gem 'discard' | ||||
| gem "rspec-rails", "~> 8.0", :groups => [:development, :test] | 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) | tzinfo (~> 2.0, >= 2.0.5) | ||||
| uri (>= 0.13.1) | uri (>= 0.13.1) | ||||
| ast (2.4.3) | 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) | base64 (0.2.0) | ||||
| bcrypt_pbkdf (1.1.1) | bcrypt_pbkdf (1.1.1) | ||||
| bcrypt_pbkdf (1.1.1-arm64-darwin) | bcrypt_pbkdf (1.1.1-arm64-darwin) | ||||
| @@ -157,6 +176,7 @@ GEM | |||||
| pp (>= 0.6.0) | pp (>= 0.6.0) | ||||
| rdoc (>= 4.0.0) | rdoc (>= 4.0.0) | ||||
| reline (>= 0.4.2) | reline (>= 0.4.2) | ||||
| jmespath (1.6.2) | |||||
| json (2.12.0) | json (2.12.0) | ||||
| jwt (2.10.1) | jwt (2.10.1) | ||||
| base64 | base64 | ||||
| @@ -441,6 +461,7 @@ PLATFORMS | |||||
| x86_64-linux-musl | x86_64-linux-musl | ||||
| DEPENDENCIES | DEPENDENCIES | ||||
| aws-sdk-s3 | |||||
| bootsnap | bootsnap | ||||
| brakeman | brakeman | ||||
| diff-lcs | 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" | # config.asset_host = "http://assets.example.com" | ||||
| # Store uploaded files on the local file system (see config/storage.yml for options). | # 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. | # Assume all access to the app is happening through a SSL-terminating reverse proxy. | ||||
| config.assume_ssl = true | config.assume_ssl = true | ||||
| @@ -81,4 +81,6 @@ Rails.application.routes.draw do | |||||
| resources :comments, controller: :theatre_comments, only: [:index, :create] | resources :comments, controller: :theatre_comments, only: [:index, :create] | ||||
| end | end | ||||
| resources :materials, only: [:index, :show, :create, :update, :destroy] | |||||
| end | end | ||||
| @@ -6,29 +6,10 @@ local: | |||||
| service: Disk | service: Disk | ||||
| root: <%= Rails.root.join("storage") %> | 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. | # 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| | create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "name", null: false | t.string "name", null: false | ||||
| t.string "record_type", 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 | t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true | ||||
| end | 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| | 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 "nico_tag_id", null: false | ||||
| t.bigint "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 | t.datetime "updated_at", null: false | ||||
| end | 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| | create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "sha256", limit: 64, null: false | t.string "sha256", limit: 64, null: false | ||||
| t.text "body", 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 "created_at", null: false | ||||
| t.datetime "updated_at", null: false | t.datetime "updated_at", null: false | ||||
| t.datetime "discarded_at" | 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 ["created_user_id"], name: "index_wiki_pages_on_created_user_id" | ||||
| t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" | 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 ["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_attachments", "active_storage_blobs", column: "blob_id" | ||||
| add_foreign_key "active_storage_variant_records", "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" | ||||
| add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" | add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" | ||||
| add_foreign_key "post_similarities", "posts" | 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_ips", "users" | ||||
| add_foreign_key "user_post_views", "posts" | add_foreign_key "user_post_views", "posts" | ||||
| add_foreign_key "user_post_views", "users" | 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", "tag_names" | ||||
| add_foreign_key "wiki_pages", "users", column: "created_user_id" | add_foreign_key "wiki_pages", "users", column: "created_user_id" | ||||
| add_foreign_key "wiki_pages", "users", column: "updated_user_id" | add_foreign_key "wiki_pages", "users", column: "updated_user_id" | ||||