├── data └── .gitkeep ├── db └── .gitkeep ├── logs └── .gitkeep ├── image └── cover.png ├── docker ├── php │ └── php.ini └── Dockerfile ├── app ├── views │ ├── error.php │ ├── header.php │ ├── footer.php │ └── index.php ├── models │ ├── index.php │ └── init.php └── api │ ├── verifydelete.php │ ├── verifydownload.php │ └── upload.php ├── cspell.json ├── .markdownlint.json ├── .config └── dictionary.txt ├── phpstan.neon ├── aqua.yaml ├── docker-compose.yaml ├── .gitignore ├── github-comment.yaml ├── scripts ├── composer.sh ├── test-release.sh ├── release.bat ├── check-quality.bat ├── release.sh ├── test-version.php ├── check-quality.sh └── release.php ├── asset ├── js │ ├── modal.js │ ├── common.js │ └── file-manager.js └── css │ ├── responsive-extra.css │ ├── common.css │ ├── responsive.css │ └── file-manager.css ├── composer.json ├── MIT-LICENSE.txt ├── .github ├── pull_request_template.md ├── labeler.yaml ├── workflows │ ├── pull-request.yaml │ ├── wc-document-pull-request.yaml │ ├── labeler.yaml │ ├── release.yaml │ ├── wc-update-aqua-checksums.yaml │ ├── auto-rebase-renovate-prs.yaml │ └── wc-php-test.yaml └── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml ├── aqua-checksums.json ├── src └── Core │ ├── ResponseHandler.php │ ├── Logger.php │ └── SecurityUtils.php ├── renovate.json5 ├── CHANGELOG.md ├── config └── config.php.example ├── index.php ├── phpcs.xml ├── README.md ├── download.php └── delete.php /data/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file keeps the logs directory in git 2 | -------------------------------------------------------------------------------- /db/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file keeps the logs directory in git 2 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file keeps the logs directory in git 2 | -------------------------------------------------------------------------------- /image/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shimosyan/phpUploader/HEAD/image/cover.png -------------------------------------------------------------------------------- /docker/php/php.ini: -------------------------------------------------------------------------------- 1 | [Date] 2 | date.timezone = "Asia/Tokyo" 3 | [mbstring] 4 | mbstring.internal_encoding = "UTF-8" 5 | mbstring.language = "Japanese" 6 | -------------------------------------------------------------------------------- /app/views/error.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 | 6 |
7 |
8 |
9 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en", 4 | "dictionaryDefinitions": [ 5 | { 6 | "name": "myWords", 7 | "path": "./.config/dictionary.txt" 8 | } 9 | ], 10 | "dictionaries": [ 11 | "myWords" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD024": false, 4 | "MD022": false, 5 | "MD032": false, 6 | "MD031": false, 7 | "MD041": false, 8 | "MD029": false, 9 | "MD036": false, 10 | "MD009": { 11 | "br_spaces": 2 12 | }, 13 | "MD046": { 14 | "style": "fenced" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.config/dictionary.txt: -------------------------------------------------------------------------------- 1 | phpuploader 2 | shimosyan 3 | phpcs 4 | codelytv 5 | phpstan 6 | analyse 7 | shivammathur 8 | gaurav-nelson 9 | squizlabs 10 | codesniffer 11 | phpunit 12 | softprops 13 | classmap 14 | aquaproj 15 | elif 16 | unmatch 17 | suzuki-shunsuke 18 | securefix 19 | libsqlite3 20 | a2enmod 21 | phpcbf 22 | squiz 23 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 1 3 | paths: 4 | - . 5 | excludePaths: 6 | - vendor 7 | ignoreErrors: 8 | # レガシーコードのため一時的に無視する項目 9 | - '#Variable \$[a-zA-Z_]+ might not be defined#' 10 | - '#Function extract\(\) should not be used#' 11 | reportUnmatchedIgnoredErrors: false 12 | -------------------------------------------------------------------------------- /aqua.yaml: -------------------------------------------------------------------------------- 1 | # aqua - Declarative CLI Version Manager 2 | # https://aquaproj.github.io/ 3 | checksum: 4 | enabled: true 5 | require_checksum: true 6 | # supported_envs: 7 | # - all 8 | registries: 9 | - type: standard 10 | ref: v4.446.0 # renovate: depName=aquaproj/aqua-registry 11 | packages: 12 | - name: suzuki-shunsuke/github-comment@v6.4.1 13 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: "./docker" 4 | container_name: "php_apache" 5 | ports: 6 | - "80:80" 7 | volumes: 8 | - ".:/var/www/html/" 9 | 10 | # リリース管理用PHPコンテナ 11 | php-cli: 12 | build: "./docker" 13 | container_name: "php_cli" 14 | volumes: 15 | - ".:/var/www/html/" 16 | working_dir: /var/www/html 17 | profiles: 18 | - tools 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .vscode/ 3 | .idea/ 4 | 5 | # Application data 6 | data/* 7 | db/* 8 | logs/* 9 | 10 | # Dependencies 11 | vendor/ 12 | node_modules/ 13 | 14 | # Build artifacts 15 | *.zip 16 | *.tar.gz 17 | 18 | # Environment 19 | .env 20 | config/config.php 21 | 22 | # Keep directory structure 23 | !data/.gitkeep 24 | !db/.gitkeep 25 | !logs/.gitkeep 26 | 27 | # OS files 28 | .DS_Store 29 | Thumbs.db 30 | 31 | # Temporary files 32 | *.tmp 33 | *.temp 34 | 35 | # PHP specific 36 | *.php~ 37 | 38 | # Logs 39 | *.log 40 | -------------------------------------------------------------------------------- /github-comment.yaml: -------------------------------------------------------------------------------- 1 | vars: 2 | title: "Example" 3 | body: "Example" 4 | templates: 5 | base: | 6 | {{template "link" .}} 7 | 8 | {{template "join_command" .}} 9 | 10 | {{template "hidden_combined_output" .}} 11 | post: 12 | common-error: | 13 | ## :x: {{.Vars.title}} 14 | 15 | ```text 16 | {{.Vars.body}} 17 | ``` 18 | 19 | exec: 20 | common-error: 21 | - when: ExitCode != 0 22 | template: | 23 | ## {{template "status" .}} {{.Vars.title}} 24 | 25 | {{template "base" .}} 26 | -------------------------------------------------------------------------------- /scripts/composer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Dockerを使ったComposer管理スクリプト 4 | # Usage: ./scripts/composer.sh [composer command] 5 | 6 | set -e 7 | 8 | GREEN='\033[0;32m' 9 | YELLOW='\033[1;33m' 10 | NC='\033[0m' 11 | 12 | echo -e "${GREEN}🐳 Docker Composer を実行中...${NC}" 13 | 14 | # Dockerコンテナが起動しているかチェック 15 | if ! docker-compose ps | grep -q "php_cli"; then 16 | echo -e "${YELLOW}📦 PHP CLIコンテナを起動中...${NC}" 17 | docker-compose --profile tools up -d php-cli 18 | fi 19 | 20 | # Composerコマンドを実行 21 | if [ -n "$1" ]; then 22 | docker-compose exec php-cli composer "$@" 23 | else 24 | echo -e "${YELLOW}使用方法:${NC}" 25 | echo " ./scripts/composer.sh install" 26 | echo " ./scripts/composer.sh update" 27 | echo " ./scripts/composer.sh require package-name" 28 | fi 29 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # PHP 8.1に固定(アプリケーションの安定版として固定) 2 | FROM php:8.1-apache 3 | 4 | # 作業ディレクトリを設定 5 | WORKDIR /var/www/html 6 | 7 | # パッケージリストの更新とクリーンアップの改善 8 | RUN set -eux; \ 9 | apt-get update; \ 10 | apt-get install -y --no-install-recommends \ 11 | libsqlite3-dev \ 12 | git \ 13 | unzip \ 14 | curl \ 15 | ; \ 16 | apt-get clean; \ 17 | rm -rf /var/lib/apt/lists/* 18 | 19 | # 必要な拡張機能をインストール 20 | RUN docker-php-ext-install pdo pdo_sqlite 21 | 22 | # Composerのインストール 23 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ 24 | && chmod +x /usr/local/bin/composer 25 | 26 | # PHPの設定ファイルをコピー 27 | COPY ./php/php.ini /usr/local/etc/php/ 28 | 29 | # Apache設定の最適化 30 | RUN a2enmod rewrite 31 | -------------------------------------------------------------------------------- /asset/js/modal.js: -------------------------------------------------------------------------------- 1 | 2 | function openModal(type, title, body, action){ 3 | switch (type){ 4 | case 'okcansel': 5 | $('#OKCanselModal .modal-title').html(title); 6 | $('#OKCanselModal .modal-body').html(body); 7 | 8 | if(action != null){ 9 | $('#OKCanselModal .f-action').attr('onclick', action); 10 | } 11 | $('#OKCanselModal').modal('show'); 12 | 13 | break; 14 | case 'ok': 15 | $('#OKModal .modal-title').html(title); 16 | $('#OKModal .modal-body').html(body); 17 | 18 | if(action != null){ 19 | $('#OKModal .f-action').attr('onclick', action); 20 | 21 | } 22 | $('#OKModal').modal('show'); 23 | break; 24 | 25 | } 26 | 27 | } 28 | 29 | 30 | function closeModal(){ 31 | $('#OKCanselModal').modal('hide'); 32 | $('#OKModal').modal('hide'); 33 | } 34 | -------------------------------------------------------------------------------- /scripts/test-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # バージョン管理テストスクリプト 4 | # composer.jsonとconfig.phpの連携が正しく動作するかテスト 5 | 6 | set -e 7 | 8 | GREEN='\033[0;32m' 9 | YELLOW='\033[1;33m' 10 | RED='\033[0;31m' 11 | NC='\033[0m' 12 | 13 | echo -e "${GREEN}🧪 バージョン管理テストを開始...${NC}" 14 | 15 | # Dockerコンテナが起動しているかチェック 16 | if ! docker-compose ps | grep -q "php_cli"; then 17 | echo -e "${YELLOW}📦 PHP CLIコンテナを起動中...${NC}" 18 | docker-compose --profile tools up -d php-cli 19 | fi 20 | 21 | echo -e "${YELLOW}1. 現在のバージョン情報確認${NC}" 22 | docker-compose exec php-cli php scripts/test-version.php 23 | 24 | echo -e "\n${YELLOW}2. バージョン更新テスト${NC}" 25 | echo -e "テスト用バージョン 9.9.9 に更新..." 26 | docker-compose exec php-cli php scripts/release.php 9.9.9 27 | 28 | echo -e "\n${YELLOW}3. 更新後の確認${NC}" 29 | docker-compose exec php-cli php scripts/test-version.php 30 | 31 | echo -e "\n${YELLOW}4. 元のバージョンに戻す${NC}" 32 | docker-compose exec php-cli php scripts/release.php 1.2.1 33 | 34 | echo -e "\n${GREEN}✅ テスト完了!${NC}" 35 | -------------------------------------------------------------------------------- /app/models/index.php: -------------------------------------------------------------------------------- 1 | index(); 11 | //配列キーが設定されている配列なら展開 12 | if (!is_null($ret)) { 13 | if (is_array($ret)) { 14 | extract($ret); 15 | } 16 | } 17 | 18 | //データベースの作成・オープン 19 | try { 20 | $db = new \PDO('sqlite:' . $ret['dbDirectoryPath'] . '/uploader.db'); 21 | } catch (\Exception $e) { 22 | $error = '500 - データベースの接続に失敗しました。'; 23 | exit; 24 | } 25 | 26 | // デフォルトのフェッチモードを連想配列形式に設定 27 | // (毎回\PDO::FETCH_ASSOCを指定する必要が無くなる) 28 | $db->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC); 29 | 30 | // 選択 (プリペアドステートメント) 31 | $stmt = $db->prepare('SELECT * FROM uploaded'); 32 | $stmt->execute(); 33 | $r = $stmt->fetchAll(); 34 | 35 | return [ 36 | 'data' => $r, 37 | ]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shimosyan/php-uploader", 3 | "description": "Simple PHP file uploader with download/delete authentication", 4 | "version": "2.0.1", 5 | "type": "project", 6 | "keywords": [ 7 | "php", 8 | "uploader", 9 | "file-sharing" 10 | ], 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "shimosyan" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.1", 19 | "ext-pdo": "*", 20 | "ext-sqlite3": "*", 21 | "ext-openssl": "*", 22 | "ext-json": "*" 23 | }, 24 | "require-dev": { 25 | "squizlabs/php_codesniffer": "^4.0", 26 | "phpstan/phpstan": "^2.0" 27 | }, 28 | "autoload": { 29 | "classmap": [ 30 | "app/models/" 31 | ] 32 | }, 33 | "config": { 34 | "optimize-autoloader": true 35 | }, 36 | "scripts": { 37 | "check-syntax": "find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l", 38 | "release": "php scripts/release.php" 39 | } 40 | } -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 shimosyan 2 | https://micmnis.net/ 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a 5 | copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📋 変更内容 2 | 3 | ### 変更の種類 4 | 5 | - [ ] 🐛 バグ修正 6 | - [ ] ✨ 新機能 7 | - [ ] 📚 ドキュメント更新 8 | - [ ] 🔧 設定変更 9 | - [ ] ♻️ リファクタリング 10 | - [ ] 🧪 テスト追加/修正 11 | - [ ] 🚀 パフォーマンス改善 12 | 13 | ### 変更の詳細 14 | 15 | 16 | ## 🧪 テスト 17 | 18 | ### 実行したテスト 19 | 20 | - [ ] 手動テスト完了 21 | - [ ] 自動テスト追加/更新 22 | - [ ] Docker環境での動作確認 23 | - [ ] 複数PHP版での動作確認 24 | 25 | ### テスト手順 26 | 27 | 28 | ```bash 29 | # テスト実行例 30 | docker-compose up -d web 31 | # ブラウザで http://localhost にアクセスして確認 32 | ``` 33 | 34 | ## 📝 チェックリスト 35 | 36 | ### コード品質 37 | 38 | - [ ] コードは既存のスタイルに従っている 39 | - [ ] PHPの構文エラーがない 40 | - [ ] セキュリティ上の問題がない 41 | - [ ] 不要なコメントやデバッグコードがない 42 | 43 | ### ドキュメント 44 | 45 | - [ ] README.mdに必要な変更を反映済み 46 | - [ ] CHANGELOG.mdを更新済み(該当する場合) 47 | - [ ] コメントが適切に更新されている 48 | 49 | ### 互換性 50 | 51 | - [ ] 既存の機能に影響しない 52 | - [ ] PHP 8.1+ で動作する 53 | - [ ] 設定ファイルの形式に変更がない(破壊的変更の場合は明記) 54 | 55 | ## 🔗 関連Issue 56 | 57 | Fixes #(issue番号) 58 | 59 | ## 📸 スクリーンショット 60 | 61 | 62 | ## 🎯 レビュー観点 63 | 64 | 65 | ## 🔄 追加情報 66 | 67 | -------------------------------------------------------------------------------- /.github/labeler.yaml: -------------------------------------------------------------------------------- 1 | # 設定ファイル関連 2 | "area/config": 3 | - changed-files: 4 | - any-glob-to-any-file: 5 | - config/**/* 6 | - "config.php.example" 7 | 8 | # API関連 9 | "area/api": 10 | - changed-files: 11 | - any-glob-to-any-file: 12 | - app/api/**/* 13 | 14 | # UI/フロントエンド関連 15 | "area/frontend": 16 | - changed-files: 17 | - any-glob-to-any-file: 18 | - app/views/**/* 19 | - asset/**/* 20 | 21 | # データベース関連 22 | "area/database": 23 | - changed-files: 24 | - any-glob-to-any-file: 25 | - app/models/**/* 26 | - db/**/* 27 | 28 | # Docker関連 29 | "area/docker": 30 | - changed-files: 31 | - any-glob-to-any-file: 32 | - docker/**/* 33 | - docker-compose.yaml 34 | - Dockerfile 35 | 36 | # ドキュメント関連 37 | "area/documentation": 38 | - changed-files: 39 | - any-glob-to-any-file: 40 | - "*.md" 41 | - docs/**/* 42 | 43 | # CI/CD関連 44 | "area/ci": 45 | - changed-files: 46 | - any-glob-to-any-file: 47 | - .github/**/* 48 | 49 | # セキュリティ関連 50 | "area/security": 51 | - changed-files: 52 | - any-glob-to-any-file: 53 | - config/**/* 54 | - app/api/verify* 55 | 56 | # リリース関連 57 | "area/release": 58 | - changed-files: 59 | - any-glob-to-any-file: 60 | - scripts/release.* 61 | - composer.json 62 | - CHANGELOG.md 63 | -------------------------------------------------------------------------------- /scripts/release.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | REM Docker を使ったリリース管理スクリプト(Windows版) 5 | REM Usage: scripts\release.bat [version] 6 | 7 | echo 🐳 Docker を使用してリリース管理を実行中... 8 | 9 | REM Dockerコンテナが起動しているかチェック 10 | docker-compose ps | findstr "php_cli" >nul 11 | if errorlevel 1 ( 12 | echo 📦 PHP CLIコンテナを起動中... 13 | docker-compose --profile tools up -d php-cli 14 | if errorlevel 1 ( 15 | echo ❌ Dockerコンテナの起動に失敗しました 16 | exit /b 1 17 | ) 18 | ) 19 | 20 | REM バージョン引数をPHPスクリプトに渡す 21 | if "%~1"=="" ( 22 | echo 📋 現在のバージョンを確認中... 23 | docker-compose exec php-cli php scripts/release.php 24 | ) else ( 25 | echo 🔄 バージョンを %~1 に更新中... 26 | docker-compose exec php-cli php scripts/release.php %~1 27 | 28 | if errorlevel 1 ( 29 | echo ❌ バージョン更新に失敗しました 30 | exit /b 1 31 | ) 32 | 33 | echo ✅ バージョン更新完了! 34 | echo. 35 | echo 次の手順: 36 | echo 1. git add . 37 | echo 2. git commit -m "Bump version to %~1" 38 | echo 3. git tag v%~1 39 | echo 4. git push origin main --tags 40 | echo. 41 | 42 | REM --push オプションが指定された場合、自動でGitプッシュまで実行 43 | if "%~2"=="--push" ( 44 | echo 🚀 Gitに変更をプッシュ中... 45 | git add . 46 | git commit -m "Bump version to %~1" 47 | git tag v%~1 48 | git push origin main --tags 49 | echo ✅ リリース完了!GitHub Actionsがリリースを作成します 50 | ) 51 | ) 52 | 53 | echo 🎉 完了! 54 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - master 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} # デプロイしないため PR レベルの排他制御 11 | cancel-in-progress: true # PR の更新があった場合は前のジョブをキャンセルする 12 | 13 | permissions: {} 14 | 15 | jobs: 16 | status-check: 17 | needs: 18 | - document-test 19 | - update-aqua-checksums 20 | - php-test 21 | runs-on: ubuntu-latest 22 | permissions: {} 23 | 24 | if: failure() 25 | steps: 26 | - run: exit 1 27 | 28 | document-test: 29 | uses: ./.github/workflows/wc-document-pull-request.yaml 30 | secrets: 31 | APP_ID: ${{secrets.APP_ID}} 32 | PRIVATE_KEY: ${{secrets.PRIVATE_KEY}} 33 | permissions: 34 | id-token: write 35 | contents: read 36 | pull-requests: write 37 | 38 | update-aqua-checksums: 39 | uses: ./.github/workflows/wc-update-aqua-checksums.yaml 40 | secrets: 41 | APP_ID: ${{secrets.APP_ID}} 42 | PRIVATE_KEY: ${{secrets.PRIVATE_KEY}} 43 | permissions: 44 | id-token: write 45 | contents: read 46 | pull-requests: write 47 | 48 | php-test: 49 | uses: ./.github/workflows/wc-php-test.yaml 50 | secrets: 51 | APP_ID: ${{secrets.APP_ID}} 52 | PRIVATE_KEY: ${{secrets.PRIVATE_KEY}} 53 | permissions: 54 | id-token: write 55 | contents: read 56 | pull-requests: write 57 | issues: write 58 | -------------------------------------------------------------------------------- /app/views/header.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <?php echo $title ?> 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /scripts/check-quality.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | REM 開発用コード品質チェックスクリプト(Windows版) 5 | 6 | echo 🔍 コード品質チェックを開始... 7 | 8 | REM Dockerコンテナが起動しているかチェック 9 | docker-compose ps | findstr "php_cli" >nul 10 | if errorlevel 1 ( 11 | echo 📦 PHP CLIコンテナを起動中... 12 | docker-compose --profile tools up -d php-cli 13 | if errorlevel 1 ( 14 | echo ❌ Dockerコンテナの起動に失敗しました 15 | exit /b 1 16 | ) 17 | ) 18 | 19 | echo 1. PHP構文チェック 20 | docker-compose exec php-cli find . -name "*.php" -not -path "./vendor/*" -print0 | xargs -0 -n1 php -l 21 | if errorlevel 1 goto :error 22 | 23 | echo 2. Composer検証 24 | docker-compose exec php-cli composer validate --strict 25 | if errorlevel 1 goto :error 26 | 27 | echo 3. 依存関係インストール 28 | docker-compose exec php-cli composer install --dev 29 | if errorlevel 1 goto :error 30 | 31 | echo 4. PHP CodeSniffer実行 32 | docker-compose exec php-cli vendor/bin/phpcs 33 | if errorlevel 1 ( 34 | echo ⚠️ コーディング規約違反が見つかりました 35 | ) 36 | 37 | echo 5. PHPStan実行 38 | docker-compose exec php-cli vendor/bin/phpstan analyse 39 | if errorlevel 1 ( 40 | echo ⚠️ 静的解析で問題が見つかりました 41 | ) 42 | 43 | echo 6. バージョン同期テスト 44 | docker-compose exec php-cli php scripts/test-version.php 45 | if errorlevel 1 goto :error 46 | 47 | echo 7. 設定ファイルテスト 48 | docker-compose exec php-cli cp config/config.php.example config/config.php 49 | docker-compose exec php-cli php -l config/config.php 50 | if errorlevel 1 goto :error 51 | 52 | echo ✅ すべてのチェックが完了しました! 53 | exit /b 0 54 | 55 | :error 56 | echo ❌ チェック中にエラーが発生しました 57 | exit /b 1 58 | -------------------------------------------------------------------------------- /.github/workflows/wc-document-pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: Document Pull Request 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | APP_ID: 7 | required: true 8 | PRIVATE_KEY: 9 | required: true 10 | 11 | permissions: 12 | id-token: write 13 | contents: read 14 | pull-requests: write 15 | 16 | jobs: 17 | check-documentation: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 23 | 24 | - uses: aquaproj/aqua-installer@11dd79b4e498d471a9385aa9fb7f62bb5f52a73c # v4.0.4 25 | with: 26 | aqua_version: v2.55.3 27 | 28 | - id: app-token 29 | uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 30 | with: 31 | app-id: ${{ secrets.APP_ID }} 32 | private-key: ${{ secrets.PRIVATE_KEY }} 33 | 34 | - run: github-comment exec -- github-comment hide 35 | env: 36 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 37 | 38 | - name: Setup Node.js 39 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 40 | with: 41 | node-version: '24' 42 | 43 | - name: Install markdownlint 44 | run: npm install -g markdownlint-cli2 45 | 46 | - name: Lint Markdown Files 47 | run: github-comment exec -k common-error -var env:Staging -var title:"Check Markdown Failed" -- markdownlint-cli2 "**/*.md" --config ".markdownlint.json" 48 | env: 49 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 50 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Docker を使ったリリース管理スクリプト 4 | # Usage: ./scripts/release.sh [version] 5 | 6 | set -e 7 | 8 | # カラー出力用 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | NC='\033[0m' # No Color 13 | 14 | echo -e "${GREEN}🐳 Docker を使用してリリース管理を実行中...${NC}" 15 | 16 | # Dockerコンテナが起動しているかチェック 17 | if ! docker-compose ps | grep -q "php_cli"; then 18 | echo -e "${YELLOW}📦 PHP CLIコンテナを起動中...${NC}" 19 | docker-compose --profile tools up -d php-cli 20 | fi 21 | 22 | # バージョン引数をPHPスクリプトに渡す 23 | if [ -n "$1" ]; then 24 | echo -e "${GREEN}🔄 バージョンを $1 に更新中...${NC}" 25 | docker-compose exec php-cli php scripts/release.php "$1" 26 | 27 | if [ $? -eq 0 ]; then 28 | echo -e "${GREEN}✅ バージョン更新完了!${NC}" 29 | echo -e "${YELLOW}次の手順:${NC}" 30 | echo -e " 1. git add ." 31 | echo -e " 2. git commit -m \"Bump version to $1\"" 32 | echo -e " 3. git tag v$1" 33 | echo -e " 4. git push origin main --tags" 34 | echo "" 35 | echo -e "${YELLOW}または一括実行:${NC}" 36 | echo -e " ./scripts/release.sh $1 --push" 37 | else 38 | echo -e "${RED}❌ バージョン更新に失敗しました${NC}" 39 | exit 1 40 | fi 41 | 42 | # --push オプションが指定された場合、自動でGitプッシュまで実行 43 | if [ "$2" = "--push" ]; then 44 | echo -e "${YELLOW}🚀 Gitに変更をプッシュ中...${NC}" 45 | git add . 46 | git commit -m "Bump version to $1" 47 | git tag "v$1" 48 | git push origin main --tags 49 | echo -e "${GREEN}✅ リリース完了!GitHub Actionsがリリースを作成します${NC}" 50 | fi 51 | else 52 | echo -e "${YELLOW}📋 現在のバージョンを確認中...${NC}" 53 | docker-compose exec php-cli php scripts/release.php 54 | fi 55 | 56 | echo -e "${GREEN}🎉 完了!${NC}" 57 | -------------------------------------------------------------------------------- /scripts/test-version.php: -------------------------------------------------------------------------------- 1 | index(); 45 | 46 | $configVersion = $configData['version'] ?? 'N/A'; 47 | echo "⚙️ config.php バージョン: $configVersion\n"; 48 | 49 | // 一致確認 50 | if ($expectedVersion === $configVersion) { 51 | echo "✅ バージョンが一致しています!\n"; 52 | echo "\n=== その他の設定情報 ===\n"; 53 | echo 'Title: ' . $configData['title'] . "\n"; 54 | echo 'Max file size: ' . $configData['maxFileSize'] . "MB\n"; 55 | echo 'Allowed extensions: ' . implode(', ', $configData['extension']) . "\n"; 56 | exit(0); 57 | } else { 58 | echo "❌ バージョンが一致しません\n"; 59 | echo " 期待値: $expectedVersion\n"; 60 | echo " 実際の値: $configVersion\n"; 61 | exit(1); 62 | } 63 | -------------------------------------------------------------------------------- /scripts/check-quality.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 開発用コード品質チェックスクリプト 4 | 5 | set -e 6 | 7 | GREEN='\033[0;32m' 8 | YELLOW='\033[1;33m' 9 | RED='\033[0;31m' 10 | NC='\033[0m' 11 | 12 | echo -e "${GREEN}🔍 コード品質チェックを開始...${NC}" 13 | 14 | # Dockerコンテナが起動しているかチェック 15 | if ! docker-compose ps | grep -q "php_cli"; then 16 | echo -e "${YELLOW}📦 PHP CLIコンテナを起動中...${NC}" 17 | docker-compose --profile tools up -d php-cli 18 | fi 19 | 20 | echo -e "${YELLOW}1. PHP構文チェック${NC}" 21 | docker-compose exec php-cli find . -name "*.php" -not -path "./vendor/*" -print0 | xargs -0 -n1 php -l 22 | 23 | echo -e "${YELLOW}2. Composer検証${NC}" 24 | docker-compose exec php-cli composer validate --strict 25 | 26 | echo -e "${YELLOW}3. 依存関係インストール${NC}" 27 | docker-compose exec php-cli composer install --dev 28 | 29 | echo -e "${YELLOW}4. PHP CodeSniffer実行${NC}" 30 | if docker-compose exec php-cli test -f "vendor/bin/phpcs"; then 31 | docker-compose exec php-cli vendor/bin/phpcs 32 | else 33 | echo -e "${RED}PHPCS not found, installing...${NC}" 34 | docker-compose exec php-cli composer require --dev squizlabs/php_codesniffer 35 | docker-compose exec php-cli vendor/bin/phpcs 36 | fi 37 | 38 | echo -e "${YELLOW}5. PHPStan実行${NC}" 39 | if docker-compose exec php-cli test -f "vendor/bin/phpstan"; then 40 | docker-compose exec php-cli vendor/bin/phpstan analyse 41 | else 42 | echo -e "${RED}PHPStan not found, installing...${NC}" 43 | docker-compose exec php-cli composer require --dev phpstan/phpstan 44 | docker-compose exec php-cli vendor/bin/phpstan analyse 45 | fi 46 | 47 | echo -e "${YELLOW}6. バージョン同期テスト${NC}" 48 | docker-compose exec php-cli php scripts/test-version.php 49 | 50 | echo -e "${YELLOW}7. 設定ファイルテスト${NC}" 51 | docker-compose exec php-cli cp config/config.php.example config/config.php 52 | docker-compose exec php-cli php -l config/config.php 53 | 54 | echo -e "${GREEN}✅ すべてのチェックが完了しました!${NC}" 55 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yaml: -------------------------------------------------------------------------------- 1 | name: Auto Label 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-labeler-${{ github.ref }} # デプロイしないため PR レベルの排他制御 11 | cancel-in-progress: false # PR の更新があった場合は前のジョブをキャンセルしない 12 | 13 | permissions: 14 | id-token: write 15 | contents: read 16 | pull-requests: write 17 | issues: write 18 | 19 | jobs: 20 | label: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 26 | 27 | - id: app-token 28 | uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 29 | with: 30 | app-id: ${{ secrets.APP_ID }} 31 | private-key: ${{ secrets.PRIVATE_KEY }} 32 | 33 | - name: Auto Label PR 34 | uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 35 | with: 36 | repo-token: ${{ steps.app-token.outputs.token }} 37 | configuration-path: .github/labeler.yaml 38 | 39 | size-label: 40 | runs-on: ubuntu-latest 41 | 42 | steps: 43 | - id: app-token 44 | uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 45 | with: 46 | app-id: ${{ secrets.APP_ID }} 47 | private-key: ${{ secrets.PRIVATE_KEY }} 48 | 49 | - name: Add size labels 50 | uses: codelytv/pr-size-labeler@4ec67706cd878fbc1c8db0a5dcd28b6bb412e85a # v1.10.3 51 | with: 52 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 53 | xs_label: 'size/XS' 54 | xs_max_size: 10 55 | s_label: 'size/S' 56 | s_max_size: 100 57 | m_label: 'size/M' 58 | m_max_size: 500 59 | l_label: 'size/L' 60 | l_max_size: 1000 61 | xl_label: 'size/XL' 62 | -------------------------------------------------------------------------------- /aqua-checksums.json: -------------------------------------------------------------------------------- 1 | { 2 | "checksums": [ 3 | { 4 | "id": "github_release/github.com/suzuki-shunsuke/github-comment/v6.4.1/github-comment_6.4.1_darwin_amd64.tar.gz", 5 | "checksum": "82E5D3F3B9F902EF649ECDCA0974619DB0102E4A92B67C3AEBBE7A923E67E4C4", 6 | "algorithm": "sha256" 7 | }, 8 | { 9 | "id": "github_release/github.com/suzuki-shunsuke/github-comment/v6.4.1/github-comment_6.4.1_darwin_arm64.tar.gz", 10 | "checksum": "BA269BAE0A55B672D13EB23655392ACF868011C1B5346DB91AD9BAD8F16CDE8F", 11 | "algorithm": "sha256" 12 | }, 13 | { 14 | "id": "github_release/github.com/suzuki-shunsuke/github-comment/v6.4.1/github-comment_6.4.1_linux_amd64.tar.gz", 15 | "checksum": "20983822F9B690BA5BB9D1EF69E784B6607B7CE4F2DA1A60416EE9E81355B501", 16 | "algorithm": "sha256" 17 | }, 18 | { 19 | "id": "github_release/github.com/suzuki-shunsuke/github-comment/v6.4.1/github-comment_6.4.1_linux_arm64.tar.gz", 20 | "checksum": "0871AF6A7DDFF8DE9CECE5DA021EF67D6E766AC98E8A11B3CB1A9A81986AD2FE", 21 | "algorithm": "sha256" 22 | }, 23 | { 24 | "id": "github_release/github.com/suzuki-shunsuke/github-comment/v6.4.1/github-comment_6.4.1_windows_amd64.tar.gz", 25 | "checksum": "1327F67B8F092817B46475E80D28D465741A4C1A1F9C782A0F15CF7EE047CB49", 26 | "algorithm": "sha256" 27 | }, 28 | { 29 | "id": "github_release/github.com/suzuki-shunsuke/github-comment/v6.4.1/github-comment_6.4.1_windows_arm64.tar.gz", 30 | "checksum": "1493F65493DC0C7D958C61BBF9F4D18E07D07C3148439596297F0C17D6463D2E", 31 | "algorithm": "sha256" 32 | }, 33 | { 34 | "id": "registries/github_content/github.com/aquaproj/aqua-registry/v4.446.0/registry.yaml", 35 | "checksum": "F89F41662F54892E9E06168596845AA2C84865B6ED9782B527C47622BE3035E3C126C51D80BFFF76B41C4150D41FF1F70F6B521EA52FB6B4933D6170CACEF9F3", 36 | "algorithm": "sha512" 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ 機能要求 2 | description: 新しい機能や改善の提案 3 | title: "[Feature] " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 新しい機能や改善のご提案をありがとうございます。 10 | 詳細な情報を提供していただくことで、より良い機能を実装することができます。 11 | 12 | - type: textarea 13 | id: feature-description 14 | attributes: 15 | label: 🚀 機能の概要 16 | description: 提案する機能について詳しく説明してください 17 | placeholder: どのような機能を求めているかを記載してください 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: motivation 23 | attributes: 24 | label: 💡 動機・理由 25 | description: なぜこの機能が必要なのか、どのような問題を解決するのかを説明してください 26 | placeholder: 現在の問題点や、この機能があることでどう改善されるかを記載してください 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: proposed-solution 32 | attributes: 33 | label: 🔧 提案する解決方法 34 | description: どのように実装すれば良いか、アイデアがあれば記載してください 35 | placeholder: 具体的な実装方法やUIの提案があれば記載してください 36 | 37 | - type: textarea 38 | id: alternatives 39 | attributes: 40 | label: 🔄 代替案 41 | description: 他に考えられる解決方法があれば記載してください 42 | 43 | - type: dropdown 44 | id: priority 45 | attributes: 46 | label: 📊 優先度 47 | description: この機能の重要度をお選びください 48 | options: 49 | - 高(緊急に必要) 50 | - 中(あると便利) 51 | - 低(将来的にあれば良い) 52 | validations: 53 | required: true 54 | 55 | - type: checkboxes 56 | id: feature-type 57 | attributes: 58 | label: 🏷️ 機能の種類 59 | description: 該当するものをすべて選択してください 60 | options: 61 | - label: UI/UX改善 62 | - label: セキュリティ機能 63 | - label: パフォーマンス改善 64 | - label: 管理機能 65 | - label: API機能 66 | - label: 設定オプション 67 | - label: ドキュメント改善 68 | 69 | - type: textarea 70 | id: mockups 71 | attributes: 72 | label: 🎨 モックアップ・スクリーンショット 73 | description: UIの変更を伴う場合、モックアップや参考画像があれば添付してください 74 | 75 | - type: textarea 76 | id: additional-context 77 | attributes: 78 | label: 📝 追加情報 79 | description: その他、この機能に関する情報があれば記載してください 80 | -------------------------------------------------------------------------------- /scripts/release.php: -------------------------------------------------------------------------------- 1 | isValidVersion($newVersion)) { 15 | throw new InvalidArgumentException("Invalid version format: $newVersion"); 16 | } 17 | 18 | $this->updateComposerVersion($newVersion); 19 | 20 | echo "Version updated to: $newVersion\n"; 21 | echo "config.php will automatically read the version from composer.json\n"; 22 | echo "Next steps:\n"; 23 | echo "1. git add .\n"; 24 | echo "2. git commit -m \"Bump version to $newVersion\"\n"; 25 | echo "3. git tag v$newVersion\n"; 26 | echo "4. git push origin main --tags\n"; 27 | } 28 | 29 | private function isValidVersion($version) 30 | { 31 | return preg_match('/^\d+\.\d+\.\d+$/', $version); 32 | } 33 | 34 | private function updateComposerVersion($version) 35 | { 36 | $composer = json_decode(file_get_contents($this->composerFile), true); 37 | $composer['version'] = $version; 38 | file_put_contents($this->composerFile, json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 39 | } 40 | 41 | public function getCurrentVersion() 42 | { 43 | $composer = json_decode(file_get_contents($this->composerFile), true); 44 | return $composer['version'] ?? 'unknown'; 45 | } 46 | } 47 | 48 | // コマンドライン実行 49 | if (php_sapi_name() === 'cli') { 50 | $manager = new ReleaseManager(); 51 | 52 | if (isset($argv[1])) { 53 | try { 54 | $manager->updateVersion($argv[1]); 55 | } catch (Exception $e) { 56 | echo 'Error: ' . $e->getMessage() . "\n"; 57 | exit(1); 58 | } 59 | } else { 60 | echo 'Current version: ' . $manager->getCurrentVersion() . "\n"; 61 | echo "Usage: php scripts/release.php \n"; 62 | echo "Example: php scripts/release.php 1.3.0\n"; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /asset/css/responsive-extra.css: -------------------------------------------------------------------------------- 1 | /* ================================================= 2 | レスポンシブデザイン最終調整とアクセシビリティ 3 | ================================================= */ 4 | 5 | /* より小さなスクリーン向け調整 */ 6 | @media (max-width: 480px) { 7 | .container { 8 | padding: 10px 5px; 9 | } 10 | 11 | .form-section { 12 | padding: 15px; 13 | } 14 | 15 | .btn-submit { 16 | font-size: 16px; 17 | padding: 12px 20px; 18 | } 19 | 20 | .file-card { 21 | margin-bottom: 10px; 22 | } 23 | 24 | .file-card__header { 25 | padding: 12px 15px; 26 | } 27 | 28 | .file-card__details { 29 | padding: 15px; 30 | } 31 | } 32 | 33 | /* タブレット向け調整 */ 34 | @media (min-width: 769px) and (max-width: 1024px) { 35 | .file-cards-container { 36 | display: none; 37 | } 38 | 39 | .file-table-container { 40 | display: block; 41 | } 42 | } 43 | 44 | /* 印刷時の調整 */ 45 | @media print { 46 | .btn, .file-card__toggle, .upload-progress { 47 | display: none !important; 48 | } 49 | 50 | .file-card { 51 | border: 1px solid #000; 52 | break-inside: avoid; 53 | } 54 | } 55 | 56 | /* アクセシビリティ改善 */ 57 | @media (prefers-reduced-motion: reduce) { 58 | * { 59 | animation-duration: 0.01ms !important; 60 | animation-iteration-count: 1 !important; 61 | transition-duration: 0.01ms !important; 62 | } 63 | } 64 | 65 | /* ハイコントラストモード対応 */ 66 | @media (prefers-contrast: high) { 67 | .file-card { 68 | border: 2px solid #000; 69 | } 70 | 71 | .file-card__header { 72 | background: #fff; 73 | color: #000; 74 | } 75 | 76 | .btn-success { 77 | background: #000; 78 | border-color: #000; 79 | } 80 | } 81 | 82 | /* ダークモード対応(必要に応じて) */ 83 | @media (prefers-color-scheme: dark) { 84 | .file-card { 85 | background: #2d3748; 86 | border-color: #4a5568; 87 | color: #e2e8f0; 88 | } 89 | 90 | .file-card__header { 91 | background: #4a5568; 92 | color: #e2e8f0; 93 | } 94 | 95 | .upload-progress { 96 | background: #2d3748; 97 | border-color: #4a5568; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | concurrency: 9 | group: release # repo レベルの排他制御 10 | cancel-in-progress: false # PR の更新があった場合は前のジョブをキャンセルしない 11 | 12 | permissions: 13 | id-token: write 14 | contents: write # Release ページの書き込みに必要 15 | 16 | jobs: 17 | release: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 22 | 23 | - id: app-token 24 | uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 25 | with: 26 | app-id: ${{ secrets.APP_ID }} 27 | private-key: ${{ secrets.PRIVATE_KEY }} 28 | 29 | - name: Setup PHP 30 | uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 31 | with: 32 | php-version: '8.1' 33 | extensions: pdo, sqlite3 34 | 35 | - name: Install dependencies 36 | run: composer install --no-dev --optimize-autoloader 37 | 38 | - name: Create release archive 39 | run: | 40 | # 不要なファイルを除外してアーカイブ作成 41 | zip -r phpUploader-${GITHUB_REF#refs/tags/}.zip . \ 42 | -x "*.git*" \ 43 | -x ".config/*" \ 44 | -x ".github/*" \ 45 | -x "node_modules/*" \ 46 | -x "image/*" \ 47 | -x "scripts/*" \ 48 | -x "tests/*" \ 49 | -x ".gitignore" \ 50 | -x ".markdownlint.json" \ 51 | -x "aqua.yaml" \ 52 | -x "aqua-checksums.json" \ 53 | -x "cspell.json" \ 54 | -x "github-comment.yaml" \ 55 | -x "phpcs.xml" \ 56 | -x "phpunit.xml" \ 57 | -x "phpstan.neon" \ 58 | -x "renovate.json" \ 59 | -x "*.md" \ 60 | 61 | - name: Create Release 62 | uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 63 | with: 64 | files: phpUploader-*.zip 65 | body_path: CHANGELOG.md 66 | draft: false 67 | prerelease: false 68 | env: 69 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 70 | -------------------------------------------------------------------------------- /src/Core/ResponseHandler.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 22 | } 23 | 24 | /** 25 | * 成功応答を送信 26 | */ 27 | public function success(string $message, array $data = []): void 28 | { 29 | $response = [ 30 | 'status' => 'success', 31 | 'message' => $message, 32 | 'data' => $data, 33 | 'timestamp' => date('c'), 34 | ]; 35 | 36 | $this->sendJson($response); 37 | } 38 | 39 | /** 40 | * エラー応答を送信 41 | */ 42 | public function error( 43 | string $message, 44 | array $validationErrors = [], 45 | int $httpCode = 400, 46 | ?string $errorCode = null 47 | ): void { 48 | $response = [ 49 | 'status' => 'error', 50 | 'message' => $message, 51 | 'timestamp' => date('c'), 52 | ]; 53 | 54 | if (!empty($validationErrors)) { 55 | $response['validation_errors'] = $validationErrors; 56 | } 57 | 58 | if ($errorCode !== null) { 59 | $response['error_code'] = $errorCode; 60 | } 61 | 62 | http_response_code($httpCode); 63 | $this->sendJson($response); 64 | } 65 | 66 | /** 67 | * JSON応答を送信 68 | */ 69 | private function sendJson(array $data): void 70 | { 71 | // 出力バッファをクリア 72 | if (ob_get_level()) { 73 | ob_clean(); 74 | } 75 | 76 | header('Content-Type: application/json; charset=utf-8'); 77 | echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); 78 | exit; 79 | } 80 | 81 | /** 82 | * リダイレクト 83 | */ 84 | public function redirect(string $url, int $httpCode = 302): void 85 | { 86 | http_response_code($httpCode); 87 | header("Location: {$url}"); 88 | exit; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/views/footer.php: -------------------------------------------------------------------------------- 1 | 4 | 22 | 23 | 42 | 43 | 44 | 45 | 46 | 47 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/wc-update-aqua-checksums.yaml: -------------------------------------------------------------------------------- 1 | name: Update aqua-checksums.json 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | APP_ID: 7 | required: true 8 | PRIVATE_KEY: 9 | required: true 10 | 11 | permissions: 12 | id-token: write 13 | contents: read 14 | pull-requests: write 15 | 16 | jobs: 17 | path-filter: 18 | # Get changed files to filter jobs 19 | timeout-minutes: 10 20 | outputs: 21 | test-files: ${{steps.changes.outputs.test-files}} 22 | runs-on: ubuntu-latest 23 | permissions: {} 24 | steps: 25 | - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 26 | id: changes 27 | with: 28 | filters: | 29 | test-files: 30 | - aqua.yaml 31 | 32 | update-aqua-checksums: 33 | needs: path-filter 34 | if: needs.path-filter.outputs.test-files == 'true' 35 | 36 | runs-on: ubuntu-24.04 37 | timeout-minutes: 15 38 | steps: 39 | - name: Checkout the repository 40 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 41 | with: 42 | persist-credentials: false 43 | - name: Install aqua 44 | uses: aquaproj/aqua-installer@11dd79b4e498d471a9385aa9fb7f62bb5f52a73c # v4.0.4 45 | with: 46 | aqua_version: v2.55.3 47 | 48 | - name: Fix aqua-checksums.json 49 | run: aqua upc -prune 50 | 51 | - name: Check if aqua-checksums.json is changed 52 | id: diff 53 | run: | 54 | # git管理されていないファイルも含めて変更を検出 55 | if [ ! -f ./aqua-checksums.json ]; then 56 | # ファイルが存在しない場合は変更なしとみなす 57 | echo "changed=0" >> "$GITHUB_OUTPUT" 58 | elif ! git ls-files --error-unmatch ./aqua-checksums.json >/dev/null 2>&1; then 59 | # ファイルが存在するがgit管理されていない場合(新規作成) 60 | echo "changed=1" >> "$GITHUB_OUTPUT" 61 | elif git diff --exit-code ./aqua-checksums.json; then 62 | # 既存ファイルで変更なし 63 | echo "changed=0" >> "$GITHUB_OUTPUT" 64 | else 65 | # 既存ファイルで変更あり 66 | echo "changed=1" >> "$GITHUB_OUTPUT" 67 | fi 68 | 69 | - name: Commit and push 70 | if: steps.diff.outputs.changed == '1' 71 | uses: suzuki-shunsuke/commit-action@87b297f0ce551411b43d1880f4fb3cbc60381055 # v0.0.14 72 | with: 73 | app_id: ${{secrets.APP_ID}} 74 | app_private_key: ${{ secrets.PRIVATE_KEY }} 75 | commit_message: "update: aqua-checksums.json" 76 | files: aqua-checksums.json 77 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 バグ報告 2 | description: バグや不具合の報告 3 | title: "[Bug] " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | バグを報告していただき、ありがとうございます。 10 | 以下の情報を提供していただくことで、問題の解決を迅速に行うことができます。 11 | 12 | - type: textarea 13 | id: bug-description 14 | attributes: 15 | label: 🐛 バグの内容 16 | description: 発生している問題について詳しく説明してください 17 | placeholder: 何が起こったか、何を期待していたかを記載してください 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: reproduction-steps 23 | attributes: 24 | label: 🔄 再現手順 25 | description: バグを再現するための手順を記載してください 26 | placeholder: | 27 | 1. '...'にアクセス 28 | 2. '...'をクリック 29 | 3. '...'を入力 30 | 4. エラーが発生 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | id: expected-behavior 36 | attributes: 37 | label: ✅ 期待される動作 38 | description: 本来どのような動作をするべきかを記載してください 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: actual-behavior 44 | attributes: 45 | label: ❌ 実際の動作 46 | description: 実際に起こった動作を記載してください 47 | validations: 48 | required: true 49 | 50 | - type: input 51 | id: version 52 | attributes: 53 | label: 📦 バージョン 54 | description: 使用しているphpUploaderのバージョン 55 | placeholder: v1.2.1 56 | validations: 57 | required: true 58 | 59 | - type: dropdown 60 | id: php-version 61 | attributes: 62 | label: 🐘 PHP バージョン 63 | description: 使用しているPHPのバージョン 64 | options: 65 | - PHP 7.4 66 | - PHP 8.0 67 | - PHP 8.1 68 | - PHP 8.2 69 | - PHP 8.3 70 | - その他 71 | validations: 72 | required: true 73 | 74 | - type: dropdown 75 | id: environment 76 | attributes: 77 | label: 🌍 実行環境 78 | description: アプリケーションを実行している環境 79 | options: 80 | - Apache 81 | - Nginx 82 | - Docker 83 | - その他 84 | validations: 85 | required: true 86 | 87 | - type: textarea 88 | id: browser-info 89 | attributes: 90 | label: 🌐 ブラウザ情報 91 | description: 使用しているブラウザとバージョン(フロントエンドの問題の場合) 92 | placeholder: Chrome 120.0.0.0, Firefox 121.0, Safari 17.0 など 93 | 94 | - type: textarea 95 | id: error-logs 96 | attributes: 97 | label: 📋 エラーログ 98 | description: 関連するエラーログがあれば貼り付けてください 99 | render: shell 100 | 101 | - type: textarea 102 | id: additional-context 103 | attributes: 104 | label: 📝 追加情報 105 | description: その他、問題の解決に役立つ情報があれば記載してください 106 | -------------------------------------------------------------------------------- /asset/css/common.css: -------------------------------------------------------------------------------- 1 | 2 | .bg-fade{ 3 | background-color: #EEE; 4 | } 5 | 6 | .bg-white{ 7 | background-color: #FFF; 8 | } 9 | 10 | .radius{ 11 | padding: 12px; 12 | border-radius: 4px; 13 | -moz-border-radius: 4px; 14 | -webkit-border-radius: 4px; 15 | -ms-border-radius: 4px; 16 | } 17 | 18 | .box-shadow{ 19 | box-shadow:0px 2px 8px -2px #777; 20 | -moz-box-shadow:0px 2px 8px -2px #777; 21 | -webkit-box-shadow:0px 2px 8px -2px #777; 22 | -ms-box-shadow:0px 2px 8px -2px #777; 23 | } 24 | 25 | .container{ 26 | padding-top: 12px; 27 | } 28 | 29 | .row{ 30 | margin-bottom: 12px; 31 | } 32 | 33 | /* Ver.2.0 エラー表示の改善 */ 34 | .panel-danger .panel-body small.text-muted { 35 | font-size: 0.85em; 36 | opacity: 0.8; 37 | display: block; 38 | margin-top: 8px; 39 | } 40 | 41 | .panel-danger .panel-body strong { 42 | color: #a94442; 43 | } 44 | 45 | /* エラーメッセージ内のコード表示 */ 46 | .error-code { 47 | font-family: 'Courier New', monospace; 48 | background-color: rgba(0,0,0,0.1); 49 | padding: 2px 4px; 50 | border-radius: 3px; 51 | font-size: 0.9em; 52 | } 53 | 54 | /* Ver.2.0 レスポンシブ対応強化 */ 55 | @media (max-width: 768px) { 56 | .container { 57 | padding: 8px; 58 | } 59 | 60 | .radius { 61 | padding: 16px; 62 | } 63 | 64 | /* フォームの2カラムレイアウトをモバイルで1カラムに */ 65 | .row .col-sm-6 { 66 | margin-bottom: 15px; 67 | } 68 | 69 | /* ボタンをフル幅に */ 70 | .btn-block { 71 | width: 100% !important; 72 | } 73 | 74 | /* アップロードセクションの調整 */ 75 | #upload .row:last-child .col-sm-offset-10 { 76 | width: 100%; 77 | margin-left: 0; 78 | } 79 | } 80 | 81 | /* アップロード進捗とエラーメッセージのスタイル改善 */ 82 | #uploadContainer, 83 | #errorContainer, 84 | #successContainer { 85 | margin-top: 15px; 86 | } 87 | 88 | /* ステータスメッセージの改善 */ 89 | .alert { 90 | border-radius: 6px; 91 | margin-bottom: 20px; 92 | padding: 12px 16px; 93 | } 94 | 95 | .alert-success { 96 | background-color: #d4edda; 97 | border-color: #c3e6cb; 98 | color: #155724; 99 | } 100 | 101 | .alert-danger { 102 | background-color: #f8d7da; 103 | border-color: #f5c6cb; 104 | color: #721c24; 105 | } 106 | 107 | /* プログレスバーの改善 */ 108 | .progress { 109 | height: 20px; 110 | border-radius: 4px; 111 | background-color: #e9ecef; 112 | } 113 | 114 | .progress-bar { 115 | transition: width 0.3s ease; 116 | } 117 | 118 | /* ファイル選択エリアの改善 */ 119 | .input-group { 120 | position: relative; 121 | } 122 | 123 | .input-group .form-control { 124 | border-top-right-radius: 0; 125 | border-bottom-right-radius: 0; 126 | } 127 | 128 | .input-group-btn .btn { 129 | border-top-left-radius: 0; 130 | border-bottom-left-radius: 0; 131 | } 132 | 133 | /* ヘルプテキストの改善 */ 134 | .help-block { 135 | margin-top: 5px; 136 | font-size: 12px; 137 | color: #6c757d; 138 | line-height: 1.4; 139 | } 140 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | '$schema': 'https://docs.renovatebot.com/renovate-schema.json', 3 | 4 | extends: [ 5 | 'config:recommended', 6 | 'helpers:pinGitHubActionDigests', 7 | 'github>aquaproj/aqua-renovate-config#2.9.0', 8 | ], 9 | 10 | labels: [ 11 | 'renovate', 12 | ], 13 | 14 | packageRules: [ 15 | { 16 | matchUpdateTypes: [ 17 | 'minor', 18 | 'patch', 19 | 'pin', 20 | 'digest', 21 | ], 22 | automerge: true, 23 | }, 24 | { 25 | matchFileNames: [ 26 | 'docker/Dockerfile', 27 | ], 28 | enabled: false, 29 | }, 30 | { 31 | description: 'Keep PHP version fixed in composer.json', 32 | matchFileNames: [ 33 | 'composer.json', 34 | ], 35 | matchPackageNames: [ 36 | 'php', 37 | ], 38 | enabled: false, 39 | }, 40 | { 41 | matchDepTypes: [ 42 | 'action', 43 | ], 44 | matchUpdateTypes: [ 45 | 'minor', 46 | 'patch', 47 | 'pin', 48 | 'digest', 49 | ], 50 | pinDigests: true, 51 | labels: [ 52 | 'action', 53 | ], 54 | automerge: true, 55 | }, 56 | { 57 | matchManagers: [ 58 | 'composer', 59 | ], 60 | matchUpdateTypes: [ 61 | 'minor', 62 | 'patch', 63 | 'pin', 64 | 'digest', 65 | ], 66 | labels: [ 67 | 'composer', 68 | ], 69 | automerge: true, 70 | }, 71 | { 72 | matchSourceUrls: [ 73 | 'https://github.com/php/php-src', 74 | ], 75 | labels: [ 76 | 'php-version', 77 | ], 78 | automerge: false, 79 | assignees: [ 80 | 'shimosyan', 81 | ] 82 | }, 83 | { 84 | description: 'Keep shivammathur/setup-php up to date for PHP version support', 85 | matchPackageNames: [ 86 | 'shivammathur/setup-php', 87 | ], 88 | labels: [ 89 | 'setup-php', 90 | 'critical', 91 | ], 92 | automerge: false, 93 | prPriority: 10, 94 | assignees: [ 95 | 'shimosyan', 96 | ], 97 | reviewers: [ 98 | 'shimosyan', 99 | ] 100 | } 101 | ], 102 | 103 | customManagers: [ 104 | { 105 | customType: 'regex', 106 | description: 'Update PHP version in GitHub Actions workflow matrix', 107 | managerFilePatterns: [ 108 | '/^\\.github/workflows/.*\\.ya?ml$/', 109 | ], 110 | matchStrings: [ 111 | "# renovate: php-version\\s*\\n\\s*-\\s*['\"]?(?[^'\"\\s]+)['\"]?", 112 | ], 113 | datasourceTemplate: 'github-releases', 114 | depNameTemplate: 'php/php-src', 115 | extractVersionTemplate: '^php-(?\\d+\\.\\d+)$', 116 | } 117 | ], 118 | 119 | prHourlyLimit: 2, 120 | prConcurrentLimit: 10, 121 | branchConcurrentLimit: 20, 122 | 123 | minimumReleaseAge: '3 days', // 公開後3日間は PR を作成しない 124 | 125 | platformAutomerge: true, 126 | 127 | timezone: 'Asia/Tokyo', 128 | dependencyDashboard: true, 129 | } 130 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.0.1] - 2025-08-03 9 | 10 | ### Fixed 11 | 12 | - 古いライブラリが残ったままになっている問題に対処 13 | 14 | ### Changed 15 | 16 | - サーバー側の処理の信頼性を向上 17 | - コーディング規約に準じた書き方を反映 18 | 19 | ### Other 20 | 21 | - その他開発者向けワークフローの充実化 22 | 23 | ## [2.0.0] - 2025-07-31 24 | 25 | **🚨 BREAKING CHANGES: Ver.2.0は内部DBの仕様が刷新されているため、Ver.1.x系との互換性がありません。** 26 | 27 | ### Changed 28 | 29 | - **PHP要件を8.1+に更新** 30 | - サーバー処理を全面更新 31 | - ファイル一覧のUIを刷新 32 | - config.phpをテンプレート化(config.php.example) 33 | - バージョン情報をcomposer.jsonから動的取得に変更 34 | - リリース管理プロセスの改善 35 | - アクセスログ機能を実装 36 | 37 | ### Security 38 | 39 | - 各認証用文字列の暗号化方式を変更 40 | - レインボーテーブル攻撃対策として、Argon2ID パスワードハッシュ化 41 | - CSRF保護を導入 42 | - セッション強化 43 | - ディレクトリトラバーサル対策として、ファイル名ハッシュ化を強制 44 | - SQL インジェクション完全対策として、PDO PreparedStatement に移行 45 | 46 | ## [1.2.1] - 2022-02-09 47 | 48 | 本アップデートには以下の脆弱性に対する対応が実施されています。 49 | 影響を受けるバージョンは以下のとおりです。 50 | 51 | - phpUploader v1.2 以前の全てのバージョン 52 | 53 | 該当バージョンの確認方法は v1.2 までは提供しておりません。トップページ右下のクレジットが以下の表記の場合はすべて v1.2 以前となります。 54 | `@shimosyan/phpUploader (GitHub)` 55 | 56 | The following vulnerabilities have been addressed in this update. 57 | Affected versions are as follows 58 | 59 | - All versions of phpUploader v1.2 and earlier 60 | 61 | We do not provide a way to check the affected versions until v1.2. If the credit in the lower right corner of the top page is as follows, all versions are v1.2 or earlier. 62 | `@shimosyan/phpUploader (GitHub)`. 63 | 64 | ### 更新方法 65 | 66 | はじめに、設定ファイル(`./config/config.php`)をバックアップしてください。 67 | バージョン 1.0 より前の製品を利用されている方は、データベースファイルなどを含むソフトウェア全データを消去してから本対策版バージョンをインストールしてください。 68 | バージョン 1.0 以降の製品を利用されている方は、ソフトウェア本体を消去してから本対策版バージョンをインストールしてください。 69 | 最後に、バックアップした設定ファイルの各値を新しくインストールした設定ファイル(`./config/config.php`)に登録してください。 70 | 71 | 本対策版バージョンは画面下部の `Assets` 欄から入手してください。 72 | 73 | First, back up your configuration file (`. /config/config.php`). 74 | If you are using a product earlier than version 1.0, please delete all data including database files before installing this countermeasure version. 75 | If you are using a product with version 1.0 or later, delete the software itself before installing this countermeasure version. 76 | Finally, add each value of the backed up configuration file to the newly installed configuration file (`. /config/config.php`). 77 | 78 | You can get this countermeasure version from the `Assets` field at the bottom of the screen. 79 | 80 | ### 脆弱性対応 81 | 82 | - Stored XSS に関する脆弱性修正を実施しました。 83 | - SQL インジェクション に関する脆弱性修正を実施しました。 84 | 85 | ### その他対応 86 | 87 | - トップページ右下のクレジット欄にバージョン情報を明記するようにしました。 88 | - 例:`@shimosyan/phpUploader v1.2.1 (GitHub)` 89 | - ファイル内の余剰な空白の消去、EOL の追加などファイルの体裁を整えました。 90 | 91 | ## [1.2] - 2019-01-07 92 | 93 | ### Added 94 | 95 | - サーバー内で格納するアップロードファイルの名称をハッシュ化するオプションを追加しました。セキュリティをより向上させたいときにお使いください。 96 | 97 | ## [1.1] - 2019-01-06 98 | 99 | ### Fixed 100 | 101 | - IE系のブラウザで日本語ファイルをダウンロードすると文字化けする問題を修正。 102 | - アップロード時に拡張子が大文字であっても通るように修正。 103 | - アップロード時にPHPで発生したエラーを表示するよう変更。 104 | - HTTPS環境では外部ライブラリが正しく参照されない問題を修正。 105 | 106 | ## [1.0] - 2017-05-07 107 | 108 | ### Added 109 | 110 | - DL及び削除にパスワードを設定できるようにしました。 111 | - 管理者用のパスワードから全てのファイルに対してDL及び削除ができるようにしました。 112 | - 各ファイルのDL数を表示できるようにしました。 113 | 114 | ### Changed 115 | 116 | - ファイルリストの並び順を新しいファイル順に変更しました。 117 | - DBファイルの構成を変更しました。 118 | 119 | ## [0.2] - 2017-05-04 120 | 121 | ### Fixed 122 | 123 | - DL時の出力バッファのゴミがダウンロードファイルに混入する不具合を修正 124 | 125 | ## [0.1] - 2017-05-04 126 | 127 | ### Added 128 | 129 | - 新規リリース 130 | -------------------------------------------------------------------------------- /config/config.php.example: -------------------------------------------------------------------------------- 1 | 'CHANGE_THIS_MASTER_KEY', 21 | 22 | // 暗号化・ハッシュ化用キー(必ず変更してください) 23 | 'key' => 'CHANGE_THIS_ENCRYPTION_KEY', 24 | 25 | // セッション用ソルト(必ず変更してください) 26 | 'sessionSalt' => 'CHANGE_THIS_sessionSalt', 27 | 28 | // アプリケーション設定 29 | 'title' => 'PHP Uploader', 30 | 'saveMaxFiles' => 500, 31 | 'maxComment' => 80, 32 | 'maxFileSize' => 2, // MB 33 | 'extension' => [ 34 | 'zip', 35 | 'rar', 36 | 'lzh', 37 | 'pdf', 38 | 'jpg', 39 | 'png', 40 | 'gif', 41 | ], 42 | 43 | // ディレクトリ設定 44 | 'dbDirectoryPath' => dirname(__DIR__) . '/db', 45 | 'dataDirectoryPath' => dirname(__DIR__) . '/data', 46 | 'logDirectoryPath' => dirname(__DIR__) . '/logs', 47 | 48 | // セキュリティ設定 49 | 'security' => [ 50 | 'minKeyLength' => 4, 51 | 'logIpAddress' => true, 52 | ], 53 | 54 | // ログ設定 55 | 'logLevel' => 'INFO', // DEBUG, INFO, WARNING, ERROR 56 | 57 | // トークン設定 58 | 'tokenExpiryMinutes' => 10, 59 | 60 | // バージョン情報(composer.jsonから自動取得) 61 | 'version' => $this->getVersion() 62 | ]; 63 | } 64 | 65 | /** 66 | * セキュリティ設定の検証を行う 67 | */ 68 | public function validateSecurityConfig(): bool 69 | { 70 | $config = $this->index(); 71 | 72 | // 基本的なセキュリティ設定をチェック 73 | if (strpos($config['master'], 'CHANGE_THIS') !== false) { 74 | return false; 75 | } 76 | 77 | if (strpos($config['key'], 'CHANGE_THIS') !== false) { 78 | return false; 79 | } 80 | 81 | if (strpos($config['sessionSalt'], 'CHANGE_THIS') !== false) { 82 | return false; 83 | } 84 | 85 | return true; 86 | } 87 | 88 | /** 89 | * 設定の検証を行う 90 | */ 91 | public function validate(): array 92 | { 93 | $errors = []; 94 | $config = $this->index(); 95 | 96 | // 必須設定の確認 97 | if ($config['master'] === 'CHANGE_THIS_MASTER_KEY_Ver2') { 98 | $errors[] = 'マスターキーを変更してください'; 99 | } 100 | 101 | if ($config['key'] === 'CHANGE_THIS_ENCRYPTION_KEY_Ver2') { 102 | $errors[] = '暗号化キーを変更してください'; 103 | } 104 | 105 | if ($config['sessionSalt'] === 'CHANGE_THIS_sessionSalt') { 106 | $errors[] = 'セッションソルトを変更してください'; 107 | } 108 | 109 | // ディレクトリの存在確認 110 | $directories = [ 111 | 'dbDirectoryPath', 112 | 'dataDirectoryPath', 113 | 'logDirectoryPath', 114 | ]; 115 | foreach ($directories as $dir) { 116 | if (!is_dir($config[$dir])) { 117 | $errors[] = "{$dir} ディレクトリが存在しません: {$config[$dir]}"; 118 | } 119 | } 120 | 121 | return $errors; 122 | } 123 | 124 | /** 125 | * composer.jsonからバージョン情報を取得 126 | */ 127 | private function getVersion(): string 128 | { 129 | $composerPath = __DIR__ . '/../composer.json'; 130 | 131 | if (file_exists($composerPath)) { 132 | $composerData = json_decode(file_get_contents($composerPath), true); 133 | if (isset($composerData['version'])) { 134 | return $composerData['version']; 135 | } 136 | } 137 | 138 | return 'x.x.x'; 139 | } 140 | 141 | /** 142 | * アプリケーションルートディレクトリを取得 143 | */ 144 | private function getAppRoot(): string 145 | { 146 | // config.phpの位置からアプリケーションルートを算出 147 | return dirname(__DIR__); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | index(); 34 | 35 | // 設定の検証 36 | if (!$configInstance->validateSecurityConfig()) { 37 | throw new Exception('設定ファイルのセキュリティ設定が不完全です。config.php を確認してください。'); 38 | } 39 | 40 | // ページパラメータの取得 41 | $page = $_GET['page'] ?? 'index'; 42 | $page = preg_replace('/[^a-zA-Z0-9_]/', '', $page); // セキュリティ: 英数字とアンダースコアのみ許可 43 | 44 | // アプリケーション初期化 45 | require_once $baseDir . '/app/models/init.php'; 46 | 47 | $initInstance = new \PHPUploader\Model\Init($config); 48 | $db = $initInstance -> initialize(); 49 | 50 | // ログ機能の初期化 51 | $logger = new \PHPUploader\Core\Logger( 52 | $config['logDirectoryPath'], 53 | $config['logLevel'], 54 | $db 55 | ); 56 | 57 | // レスポンスハンドラーの初期化 58 | $responseHandler = new \PHPUploader\Core\ResponseHandler($logger); 59 | 60 | // アクセスログの記録 61 | $logger->access(null, 'page_view', 'success'); 62 | 63 | // モデルの読み込みと実行 64 | $modelData = []; 65 | $modelPath = "./app/models/{$page}.php"; 66 | $modelQueriedName = '\\PHPUploader\\Model\\' . ucfirst($page); 67 | 68 | if (file_exists($modelPath)) { 69 | require_once $modelPath; 70 | 71 | if (class_exists($modelQueriedName)) { 72 | $model = new $modelQueriedName(); 73 | if (method_exists($model, 'index')) { 74 | $result = $model -> index(); 75 | if (is_array($result)) { 76 | $modelData = $result; 77 | } 78 | } 79 | } 80 | } 81 | 82 | // ビューの描画 83 | $viewData = array_merge($config, $modelData, [ 84 | 'logger' => $logger, 85 | 'responseHandler' => $responseHandler, 86 | 'db' => $db, 87 | 'csrfToken' => \PHPUploader\Core\SecurityUtils::generateCSRFToken(), 88 | 'statusMessage' => $_GET['deleted'] ?? null 89 | ]); 90 | 91 | // 変数の展開 92 | extract($viewData); 93 | 94 | // ヘッダーの出力 95 | require './app/views/header.php'; 96 | 97 | // メインコンテンツの出力 98 | $viewPath = "./app/views/{$page}.php"; 99 | if (file_exists($viewPath)) { 100 | require $viewPath; 101 | } else { 102 | $error = '404 - ページが見つかりません。'; 103 | require './app/views/error.php'; 104 | } 105 | 106 | // フッターの出力 107 | require './app/views/footer.php'; 108 | } catch (Exception $e) { 109 | // 緊急時のエラーハンドリング 110 | $errorMessage = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'); 111 | 112 | // ログが利用可能な場合はエラーログに記録 113 | if (isset($logger)) { 114 | $logger->error('Application Error: ' . $e->getMessage(), [ 115 | 'file' => $e->getFile(), 116 | 'line' => $e->getLine(), 117 | 'trace' => $e->getTraceAsString(), 118 | ]); 119 | } else { 120 | // ログが利用できない場合はファイルに直接記録 121 | $logMessage = date('Y-m-d H:i:s') . 122 | ' [CRITICAL] ' . $e->getMessage() . 123 | ' in ' . 124 | $e->getFile() . 125 | ' on line ' . 126 | $e->getLine() . 127 | PHP_EOL; 128 | @file_put_contents('./logs/critical.log', $logMessage, FILE_APPEND | LOCK_EX); 129 | } 130 | 131 | // シンプルなエラーページの表示 132 | http_response_code(500); 133 | echo ''; 134 | echo 'エラー'; 135 | echo '

システムエラー

'; 136 | echo '

' . $errorMessage . '

'; 137 | echo '

トップページに戻る

'; 138 | echo ''; 139 | } 140 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Coding standard for phpUploader project 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | vendor/* 16 | tests/* 17 | docker/* 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | */tests/* 26 | 27 | 28 | 29 | 30 | 31 | scripts/release.php 32 | 33 | 34 | 35 | 36 | 37 | scripts/release.php 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 0 65 | 66 | 67 | 0 68 | 69 | 70 | 0 71 | 72 | 73 | 0 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 0 82 | 83 | 84 | 85 | 86 | 0 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 0 110 | 111 | 112 | 113 | 114 | 115 | 0 116 | 117 | 118 | 119 | 0 120 | 121 | 122 | 123 | 0 124 | 125 | 126 | 127 | 0 128 | 129 | 130 | 131 | 0 132 | 133 | 134 | 135 | 0 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Name 2 | 3 | phpUploader 4 | 5 | ## Description 6 | 7 | サーバーに設置するだけで使える簡易PHPアップローダーです。 8 | 9 | ![cover](https://github.com/user-attachments/assets/bd485c47-6acd-4525-9a17-5eb38cf98fc0) 10 | 11 | ## ⚠️ 重要: Ver.2.0 の破壊的変更について 12 | 13 | **Ver.2.0 は DB の仕様を刷新したため、Ver.1.x 系との互換性がありません。** 14 | 15 | ## Requirement 16 | 17 | - PHP Version 8.1+ 18 | - SQLite (PHPにバンドルされたもので可、一部の環境によってはphp○-sqliteのインストールが必要です。) 19 | - PHP拡張: `openssl`, `json`, `mbstring`, `hash` 20 | - Webサーバー: Apache もしくは Nginx + PHP-FPM 21 | 22 | ## Usage 23 | 24 | ものすごい簡易なアップローダーなので以下の利用を想定しています。 25 | 26 | - 少人数且つ、不特定多数ではない利用者間でのファイルのやり取り 27 | 28 | ## Install 29 | 30 | ① 下記URLからダウンロードしたファイルを任意のディレクトリに展開して下さい。 31 | 32 | 33 | 34 | ② 設定ファイルを作成して下さい。 35 | 36 | ```bash 37 | # config.php.exampleをコピーして設定ファイルを作成 38 | cp config/config.php.example config/config.php 39 | ``` 40 | 41 | ③ `config/config.php`を任意の値で編集して下さい。 42 | 43 | **重要**: 以下の項目は必ず変更してください: 44 | 45 | - `master`: 管理者用キー(DLキー・DELキーのマスターキー) 46 | - `key`: 暗号化用ハッシュ(ランダムな英数字) 47 | - `sessionSalt`: セッションソルト(ランダムな英数字) 48 | 49 | ```php 50 | // 例:セキュリティのため必ず変更してください 51 | 'master' => 'YOUR_SECURE_MASTER_KEY_HERE', // マスターキー 52 | 'key' => hash('sha256', 'YOUR_ENCRYPTION_SEED_HERE'), // 暗号化キー 53 | 'sessionSalt' => hash('sha256', 'YOUR_sessionSalt'), // セッションソルト 54 | ``` 55 | 56 | ④ 設置したディレクトリにapacheまたはnginxの実行権限を付与して下さい。 57 | 58 | ④ この状態でサーバーに接続すると下記のディレクトリが自動作成されます。 59 | 60 | - DBファイル(既定値 `./db/uploader.db`) 61 | - データ設置用のディレクトリ(既定値 `./data`) 62 | - ログファイル用のディレクトリ(既定値 `./logs`) 63 | 64 | ⑤ configディレクトリとDBファイル設置用のディレクトリ(既定値 `./db`)とデータ設置用のディレクトリ(既定値 `./data`)、ログファイル用のディレクトリ(既定値 `./logs`)には`.htaccess`などを用いて外部からの接続を遮断させて下さい。 65 | 66 | **セキュリティ設定例(Apache):** 67 | 68 | ```apache 69 | # config/.htaccess 70 | 71 | Deny from all 72 | 73 | 74 | # db/.htaccess 75 | 76 | Deny from all 77 | 78 | 79 | # data/.htaccess 80 | 81 | Deny from all 82 | 83 | 84 | # logs/.htaccess 85 | 86 | Deny from all 87 | 88 | ``` 89 | 90 | ⑥ファイルがアップロードできるよう、PHPとapacheまたはnginxの設定を変更してください。 91 | 92 | ## Quick Start (Docker) 93 | 94 | Dockerを使用して素早く動作確認できます: 95 | 96 | ```bash 97 | # 1. リポジトリをクローン 98 | git clone https://github.com/shimosyan/phpUploader.git 99 | cd phpUploader 100 | 101 | # 2. 設定ファイルを作成 102 | cp config/config.php.example config/config.php 103 | 104 | # 3. 設定ファイルを編集(master, keyを変更) 105 | # エディタで config/config.php を開いて編集 106 | 107 | # 4. Dockerでサーバー起動 108 | docker-compose up -d web 109 | 110 | # 5. ブラウザで http://localhost にアクセス 111 | 112 | # 終了するとき 113 | docker-compose down web 114 | ``` 115 | 116 | ## Security Notes 117 | 118 | **設定ファイルのセキュリティ**: 119 | 120 | - `./config/config.php`は機密情報を含むため、必ず外部アクセスを遮断してください 121 | - `master`と`key`、`sessionSalt`には推測困難なランダムな値を設定してください 122 | - 本番環境では下記ディレクトリへの直接アクセスを禁止してください 123 | - `config` 124 | - `data` 125 | - `db` 126 | - `logs` 127 | 128 | **推奨セキュリティ設定**: 129 | 130 | ```php 131 | // 強力なキーの例(実際は異なる値を使用してください) 132 | 'master' => bin2hex(random_bytes(16)), // 32文字のランダム文字列 133 | 'key' => bin2hex(random_bytes(32)), // 64文字のランダム文字列 134 | 'sessionSalt' => hash('sha256', bin2hex(random_bytes(32))), // 32文字のランダム文字列 135 | ``` 136 | 137 | ## Development 138 | 139 | ### 初期セットアップ 140 | 141 | 開発を始める前に、設定ファイルを準備してください: 142 | 143 | ```bash 144 | # 設定ファイルのテンプレートをコピー 145 | cp config/config.php.example config/config.php 146 | 147 | # 設定ファイルを編集(開発用の値に変更) 148 | # master, key などをローカル開発用の値に設定 149 | ``` 150 | 151 | **注意**: `config/config.php`は`.gitignore`に含まれており、リポジトリにはコミットされません。 152 | 153 | ### バージョン管理 154 | 155 | このプロジェクトでは`composer.json`でバージョンを一元管理しています。 156 | 157 | - **composer.json**: マスターバージョン情報 158 | - **config.php**: composer.jsonから自動的にバージョンを読み取り 159 | 160 | バージョン確認テスト: 161 | 162 | ```bash 163 | # Docker環境で実行 164 | docker-compose --profile tools up -d php-cli 165 | docker-compose exec php-cli php scripts/test-version.php 166 | 167 | # 終了するとき 168 | docker-compose down php-cli 169 | ``` 170 | 171 | ### Docker環境での開発 172 | 173 | PHPがローカルにインストールされていなくても、Dockerを使って開発できます。 174 | 175 | ```bash 176 | # Webサーバーの起動 177 | docker-compose up -d web 178 | 179 | # リリース管理(Linux/Mac) 180 | ./scripts/release.sh x.x.x 181 | 182 | # リリース管理(Windows) 183 | scripts\release.bat x.x.x 184 | 185 | # 自動プッシュ付きリリース 186 | ./scripts/release.sh x.x.x --push 187 | 188 | # Composer管理 189 | ./scripts/composer.sh install 190 | ./scripts/composer.sh update 191 | ``` 192 | 193 | ### リリース手順 194 | 195 | 1. **バージョン更新**: `./scripts/release.sh x.x.x` 196 | 2. **変更確認**: 自動的に`composer.json`が更新され、`config.php`は動的に読み取ります 197 | 3. **Git操作**: 表示される手順に従ってコミット・タグ・プッシュ 198 | 4. **自動リリース**: GitHub Actionsが自動でリリースを作成 199 | 200 | **重要**: リリース時は`config/config.php.example`テンプレートが配布され、エンドユーザーが自分で設定ファイルを作成する必要があります。 201 | 202 | ## License 203 | 204 | Copyright (c) 2017 shimosyan 205 | Released under the MIT license 206 | 207 | -------------------------------------------------------------------------------- /.github/workflows/auto-rebase-renovate-prs.yaml: -------------------------------------------------------------------------------- 1 | name: Auto Rebase Renovate PRs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths: 9 | - .github/workflows/** 10 | - aqua.yaml 11 | - aqua-checksums.json 12 | workflow_dispatch: 13 | inputs: 14 | pr_numbers: 15 | description: 'Specific PR numbers to rebase (comma-separated,optional)' 16 | required: false 17 | type: string 18 | 19 | env: 20 | TARGET_LABEL_NAME: rebase 21 | 22 | permissions: 23 | id-token: write 24 | contents: read 25 | pull-requests: write 26 | 27 | jobs: 28 | get-renovate-prs: 29 | runs-on: ubuntu-latest 30 | outputs: 31 | matrix: ${{ steps.set-matrix.outputs.matrix }} 32 | has-prs: ${{ steps.set-matrix.outputs.has-prs }} 33 | steps: 34 | - id: app-token 35 | uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 36 | with: 37 | app-id: ${{ secrets.APP_ID }} 38 | private-key: ${{ secrets.PRIVATE_KEY }} 39 | 40 | - name: Get Renovate PRs 41 | id: get-prs 42 | env: 43 | GH_TOKEN: ${{ steps.app-token.outputs.token }} 44 | run: | 45 | if [ -n "${{ github.event.inputs.pr_numbers }}" ]; then 46 | # 手動実行時に特定のPR番号が指定された場合 47 | IFS=',' read -ra PR_ARRAY <<< "${{github.event.inputs.pr_numbers }}" 48 | pr_json="[" 49 | for pr in "${PR_ARRAY[@]}"; do 50 | pr_json="${pr_json}${pr}," 51 | done 52 | pr_json="${pr_json%,}]" 53 | else 54 | # RenovatePRを自動検出(aquaラベルまたはrenovateユーザーのPR) 55 | pr_json=$(gh pr list \ 56 | --repo ${{ github.repository }} \ 57 | --state open \ 58 | --json number,author,labels \ 59 | --jq '[.[] | select((.author.login == "app/renovate") or (.labels | map(.name) | contains(["dependencies", "renovate"]))) | .number]') 60 | fi 61 | echo "pr_numbers=$pr_json" >> $GITHUB_OUTPUT 62 | 63 | - name: Set Matrix 64 | id: set-matrix 65 | run: | 66 | pr_numbers='${{ steps.get-prs.outputs.pr_numbers }}' 67 | if [ "$pr_numbers" = "[]" ] || [ -z "$pr_numbers" ]; then 68 | echo "has-prs=false" >> $GITHUB_OUTPUT 69 | echo "matrix={\"pr\":[]}" >> $GITHUB_OUTPUT 70 | else 71 | echo "has-prs=true" >> $GITHUB_OUTPUT 72 | echo "matrix={\"pr\":$pr_numbers}" >> $GITHUB_OUTPUT 73 | fi 74 | 75 | add-rebase-label: 76 | needs: get-renovate-prs 77 | if: needs.get-renovate-prs.outputs.has-prs == 'true' 78 | runs-on: ubuntu-latest 79 | strategy: 80 | matrix: ${{ fromJson(needs.get-renovate-prs.outputs.matrix) }} 81 | fail-fast: false 82 | max-parallel: 10 # 同時実行数を制限 83 | steps: 84 | - id: app-token 85 | uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 86 | with: 87 | app-id: ${{ secrets.APP_ID }} 88 | private-key: ${{ secrets.PRIVATE_KEY }} 89 | 90 | - name: Check if PR needs rebase 91 | id: check-pr 92 | env: 93 | GH_TOKEN: ${{ steps.app-token.outputs.token }} 94 | run: | 95 | # PRの状態を確認 96 | pr_info=$(gh pr view ${{ matrix.pr }} \ 97 | --repo ${{ github.repository }} \ 98 | --json mergeable,labels,state) 99 | 100 | mergeable=$(echo "$pr_info" | jq -r '.mergeable') 101 | state=$(echo "$pr_info" | jq -r '.state') 102 | has_rebase_label=$(echo "$pr_info" | jq '.labels | map(.name) | contains(["${{ env.TARGET_LABEL_NAME }}"])') 103 | 104 | echo "mergeable=$mergeable" >> $GITHUB_OUTPUT 105 | echo "state=$state" >> $GITHUB_OUTPUT 106 | echo "has_rebase_label=$has_rebase_label" >> $GITHUB_OUTPUT 107 | 108 | - name: Add rebase label 109 | if: | 110 | steps.check-pr.outputs.state == 'OPEN' && 111 | steps.check-pr.outputs.has_rebase_label == 'false' 112 | env: 113 | GH_TOKEN: ${{ steps.app-token.outputs.token }} 114 | run: | 115 | echo "Adding rebase label to PR #${{ matrix.pr }}" 116 | gh pr edit ${{ matrix.pr }} \ 117 | --repo ${{ github.repository }} \ 118 | --add-label ${{ env.TARGET_LABEL_NAME }} 119 | 120 | summary: 121 | needs: 122 | - get-renovate-prs 123 | - add-rebase-label 124 | if: always() 125 | runs-on: ubuntu-latest 126 | steps: 127 | - name: Summary 128 | run: | 129 | if [ "${{ needs.get-renovate-prs.outputs.has-prs }}" == "false" ]; then 130 | echo "📝 No Renovate PRs found to rebase" 131 | else 132 | echo "✅ Rebase labels have been processed for Renovate PRs" 133 | echo "Renovate will rebase the PRs on its next run." 134 | fi 135 | -------------------------------------------------------------------------------- /app/api/verifydelete.php: -------------------------------------------------------------------------------- 1 | index(); 31 | 32 | // アプリケーション初期化 33 | require_once $baseDir . '/app/models/init.php'; 34 | 35 | $initInstance = new \PHPUploader\Model\Init($config); 36 | $db = $initInstance -> initialize(); 37 | 38 | // ログとレスポンスハンドラーの初期化 39 | $logger = new \PHPUploader\Core\Logger($config['logDirectoryPath'], $config['logLevel'], $db); 40 | $responseHandler = new \PHPUploader\Core\ResponseHandler($logger); 41 | 42 | // 入力データの取得 43 | $fileId = (int)($_POST['id'] ?? 0); 44 | $inputKey = $_POST['key'] ?? ''; 45 | 46 | if ($fileId <= 0) { 47 | $responseHandler->error('ファイルIDが指定されていません。', [], 400); 48 | } 49 | 50 | // CSRFトークンの検証 51 | if (!\PHPUploader\Core\SecurityUtils::validateCSRFToken($_POST['csrf_token'] ?? '')) { 52 | $logger->warning('CSRF token validation failed in delete verify', ['file_id' => $fileId]); 53 | $responseHandler->error('無効なリクエストです。ページを再読み込みしてください。', [], 403); 54 | } 55 | 56 | // ファイル情報の取得 57 | $fileStmt = $db->prepare('SELECT * FROM uploaded WHERE id = :id'); 58 | $fileStmt->execute(['id' => $fileId]); 59 | $fileData = $fileStmt->fetch(); 60 | 61 | if (!$fileData) { 62 | $logger->warning('File not found for delete', ['file_id' => $fileId]); 63 | $responseHandler->error('ファイルが見つかりません。', [], 404); 64 | } 65 | 66 | // マスターキーチェック 67 | $isValidAuth = false; 68 | if ($inputKey === $config['master']) { 69 | $isValidAuth = true; 70 | $logger->info('Master key used for delete', ['file_id' => $fileId]); 71 | } else { 72 | // 削除キーが設定されていない場合 73 | if (empty($fileData['del_key_hash'])) { 74 | $isValidAuth = true; 75 | } else { 76 | // ハッシュ化されたキーとの照合 77 | if (!empty($inputKey)) { 78 | $isValidAuth = \PHPUploader\Core\SecurityUtils::verifyPassword($inputKey, $fileData['del_key_hash']); 79 | } else { 80 | // キーが設定されているが、入力されていない場合 81 | $isValidAuth = false; 82 | } 83 | } 84 | } 85 | 86 | if (!$isValidAuth) { 87 | // 削除キーが設定されているが、入力されていない場合 88 | if (!empty($fileData['del_key_hash']) && empty($inputKey)) { 89 | $logger->info('Delete key required', ['file_id' => $fileId]); 90 | $responseHandler->error('削除キーの入力が必要です。', [], 200, 'AUTH_REQUIRED'); 91 | } else { 92 | // キーが間違っている場合 93 | $logger->warning('Invalid delete key', ['file_id' => $fileId]); 94 | $responseHandler->error('削除キーが正しくありません。', [], 200, 'INVALID_KEY'); 95 | } 96 | } 97 | 98 | // 既存の期限切れトークンをクリーンアップ 99 | $cleanupStmt = $db->prepare('DELETE FROM access_tokens WHERE expires_at < :now'); 100 | $cleanupStmt->execute(['now' => time()]); 101 | 102 | // ワンタイムトークンの生成 103 | $token = \PHPUploader\Core\SecurityUtils::generateRandomToken(32); 104 | $expiresAt = time() + ($config['tokenExpiryMinutes'] * 60); 105 | 106 | // トークンをデータベースに保存 107 | $tokenStmt = $db->prepare(' 108 | INSERT INTO access_tokens (file_id, token, token_type, expires_at, ip_address) 109 | VALUES (:file_id, :token, :token_type, :expires_at, :ip_address) 110 | '); 111 | 112 | $tokenData = [ 113 | 'file_id' => $fileId, 114 | 'token' => $token, 115 | 'token_type' => 'delete', 116 | 'expires_at' => $expiresAt, 117 | 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, 118 | ]; 119 | 120 | if (!$tokenStmt->execute($tokenData)) { 121 | $responseHandler->error('削除トークンの生成に失敗しました。', [], 500); 122 | } 123 | 124 | // アクセスログの記録 125 | $logger->access($fileId, 'delete_verify', 'success'); 126 | 127 | // 成功レスポンス 128 | $responseHandler->success('削除準備が完了しました。', [ 129 | 'id' => $fileId, 130 | 'token' => $token, 131 | 'expires_at' => $expiresAt, 132 | 'file_name' => $fileData['origin_file_name'], 133 | ]); 134 | } catch (Exception $e) { 135 | // 緊急時のエラーハンドリング 136 | $logger->error('Delete verify API Error: ' . $e->getMessage(), [ 137 | 'file' => $e->getFile(), 138 | 'line' => $e->getLine(), 139 | 'file_id' => $fileId, 140 | ]); 141 | 142 | $responseHandler->error('システムエラーが発生しました。', [], 500); 143 | } 144 | -------------------------------------------------------------------------------- /app/api/verifydownload.php: -------------------------------------------------------------------------------- 1 | index(); 31 | 32 | // アプリケーション初期化 33 | require_once $baseDir . '/app/models/init.php'; 34 | 35 | $initInstance = new \PHPUploader\Model\Init($config); 36 | $db = $initInstance -> initialize(); 37 | 38 | // ログとレスポンスハンドラーの初期化 39 | $logger = new \PHPUploader\Core\Logger($config['logDirectoryPath'], $config['logLevel'], $db); 40 | $responseHandler = new \PHPUploader\Core\ResponseHandler($logger); 41 | 42 | // 入力データの取得 43 | $fileId = (int)($_POST['id'] ?? 0); 44 | $inputKey = $_POST['key'] ?? ''; 45 | 46 | if ($fileId <= 0) { 47 | $responseHandler->error('ファイルIDが指定されていません。', [], 400); 48 | } 49 | 50 | // CSRFトークンの検証 51 | if (!\PHPUploader\Core\SecurityUtils::validateCSRFToken($_POST['csrf_token'] ?? '')) { 52 | $logger->warning('CSRF token validation failed in download verify', ['file_id' => $fileId]); 53 | $responseHandler->error('無効なリクエストです。ページを再読み込みしてください。', [], 403); 54 | } 55 | 56 | // ファイル情報の取得 57 | $fileStmt = $db->prepare('SELECT * FROM uploaded WHERE id = :id'); 58 | $fileStmt->execute(['id' => $fileId]); 59 | $fileData = $fileStmt->fetch(); 60 | 61 | if (!$fileData) { 62 | $logger->warning('File not found for download', ['file_id' => $fileId]); 63 | $responseHandler->error('ファイルが見つかりません。', [], 404); 64 | } 65 | 66 | // マスターキーチェック 67 | $isValidAuth = false; 68 | if ($inputKey === $config['master']) { 69 | $isValidAuth = true; 70 | $logger->info('Master key used for download', ['file_id' => $fileId]); 71 | } else { 72 | // ダウンロードキーが設定されていない場合 73 | if (empty($fileData['dl_key_hash'])) { 74 | $isValidAuth = true; 75 | } else { 76 | // ハッシュ化されたキーとの照合 77 | if (!empty($inputKey)) { 78 | $isValidAuth = \PHPUploader\Core\SecurityUtils::verifyPassword($inputKey, $fileData['dl_key_hash']); 79 | } else { 80 | // キーが設定されているが、入力されていない場合 81 | $isValidAuth = false; 82 | } 83 | } 84 | } 85 | 86 | if (!$isValidAuth) { 87 | // ダウンロードキーが設定されているが、入力されていない場合 88 | if (!empty($fileData['dl_key_hash']) && empty($inputKey)) { 89 | $logger->info('Download key required', ['file_id' => $fileId]); 90 | $responseHandler->error('ダウンロードキーの入力が必要です。', [], 200, 'AUTH_REQUIRED'); 91 | } else { 92 | // キーが間違っている場合 93 | $logger->warning('Invalid download key', ['file_id' => $fileId]); 94 | $responseHandler->error('ダウンロードキーが正しくありません。', [], 200, 'INVALID_KEY'); 95 | } 96 | } 97 | 98 | // 既存の期限切れトークンをクリーンアップ 99 | $cleanupStmt = $db->prepare('DELETE FROM access_tokens WHERE expires_at < :now'); 100 | $cleanupStmt->execute(['now' => time()]); 101 | 102 | // ワンタイムトークンの生成 103 | $token = \PHPUploader\Core\SecurityUtils::generateRandomToken(32); 104 | $expiresAt = time() + ($config['tokenExpiryMinutes'] * 60); 105 | 106 | // トークンをデータベースに保存 107 | $tokenStmt = $db->prepare(' 108 | INSERT INTO access_tokens (file_id, token, token_type, expires_at, ip_address) 109 | VALUES (:file_id, :token, :token_type, :expires_at, :ip_address) 110 | '); 111 | 112 | $tokenData = [ 113 | 'file_id' => $fileId, 114 | 'token' => $token, 115 | 'token_type' => 'download', 116 | 'expires_at' => $expiresAt, 117 | 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, 118 | ]; 119 | 120 | if (!$tokenStmt->execute($tokenData)) { 121 | $responseHandler->error('ダウンロードトークンの生成に失敗しました。', [], 500); 122 | } 123 | 124 | // アクセスログの記録 125 | $logger->access($fileId, 'download_verify', 'success'); 126 | 127 | // 成功レスポンス 128 | $responseHandler->success('ダウンロード準備が完了しました。', [ 129 | 'id' => $fileId, 130 | 'token' => $token, 131 | 'expires_at' => $expiresAt, 132 | 'file_name' => $fileData['origin_file_name'] 133 | ]); 134 | } catch (Exception $e) { 135 | // 緊急時のエラーハンドリング 136 | $logger->error('Download verify API Error: ' . $e->getMessage(), [ 137 | 'file' => $e->getFile(), 138 | 'line' => $e->getLine(), 139 | 'file_id' => $fileId, 140 | ]); 141 | 142 | $responseHandler->error('システムエラーが発生しました。', [], 500); 143 | } 144 | -------------------------------------------------------------------------------- /app/views/index.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 8 | 9 | 12 | 13 | 14 | 15 |
16 |
17 |

ファイルを登録

18 |
19 | 24 | 25 |
26 | 27 |
28 | 29 | 30 | 33 | 34 |
35 |

36 | 📊 最大MBまでアップロード可能
37 | 📎 対応拡張子: 38 |

39 |
40 | 41 |
42 |
43 | 44 | 45 |

文字まで入力可能

46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 | 54 | 55 |

空白で認証なし

56 |
57 |
58 |
59 |
60 | 61 | 62 |

空白で認証なし

63 |
64 |
65 |
66 |
67 | 68 |
69 |
70 |
71 | 74 |
75 |
76 |
77 |
78 | 79 | 91 | 92 | 99 | 100 |
101 |
102 | 103 |
104 |
105 | 106 |
107 | 108 | 109 | 125 | 126 | 127 | 128 |
129 |

@shimosyan/phpUploader v (GitHub)

132 |
133 |
134 | 135 | 136 | 140 | -------------------------------------------------------------------------------- /download.php: -------------------------------------------------------------------------------- 1 | index(); 24 | 25 | // アプリケーション初期化 26 | require_once $baseDir . '/app/models/init.php'; 27 | 28 | $initInstance = new \PHPUploader\Model\Init($config); 29 | $db = $initInstance -> initialize(); 30 | 31 | // ログ機能の初期化 32 | $logger = new \PHPUploader\Core\Logger($config['logDirectoryPath'], $config['logLevel'], $db); 33 | 34 | // パラメータの取得 35 | $fileId = (int)($_GET['id'] ?? 0); 36 | $token = $_GET['key'] ?? ''; 37 | 38 | if ($fileId <= 0 || empty($token)) { 39 | $logger->warning('Invalid download parameters', ['file_id' => $fileId, 'token_provided' => !empty($token)]); 40 | header('Location: ./'); 41 | exit; 42 | } 43 | 44 | // トークンの検証 45 | $tokenStmt = $db->prepare(" 46 | SELECT t.*, u.origin_file_name, u.stored_file_name, u.size, u.file_hash 47 | FROM access_tokens t 48 | JOIN uploaded u ON t.file_id = u.id 49 | WHERE t.token = :token AND t.token_type = 'download' AND t.file_id = :file_id AND t.expires_at > :now 50 | "); 51 | 52 | $tokenStmt->execute([ 53 | 'token' => $token, 54 | 'file_id' => $fileId, 55 | 'now' => time() 56 | ]); 57 | 58 | $tokenData = $tokenStmt->fetch(); 59 | 60 | if (!$tokenData) { 61 | $logger->warning( 62 | 'Invalid or expired download token', 63 | [ 64 | 'file_id' => $fileId, 65 | 'token' => substr($token, 0, 8) . '...' 66 | ] 67 | ); 68 | header('Location: ./'); 69 | exit; 70 | } 71 | 72 | // IPアドレスの検証(設定で有効な場合) 73 | if ($config['security']['logIpAddress'] && !empty($tokenData['ip_address'])) { 74 | $currentIP = $_SERVER['REMOTE_ADDR'] ?? ''; 75 | if ($currentIP !== $tokenData['ip_address']) { 76 | $logger->warning('IP address mismatch for download', [ 77 | 'file_id' => $fileId, 78 | 'token_ip' => $tokenData['ip_address'], 79 | 'current_ip' => $currentIP, 80 | ]); 81 | // IPアドレスが異なる場合は警告ログのみで、ダウンロードは継続 82 | } 83 | } 84 | 85 | // ファイルパスの生成(ハッシュ化されたファイル名または旧形式に対応) 86 | $fileName = $tokenData['origin_file_name']; 87 | 88 | if (!empty($tokenData['stored_file_name'])) { 89 | // 新形式(ハッシュ化されたファイル名) 90 | $filePath = $config['dataDirectoryPath'] . '/' . $tokenData['stored_file_name']; 91 | } else { 92 | // 旧形式(互換性のため) 93 | $fileExtension = pathinfo($fileName, PATHINFO_EXTENSION); 94 | $filePath = $config['dataDirectoryPath'] . '/file_' . $fileId . '.' . $fileExtension; 95 | } 96 | 97 | // ファイルの存在確認 98 | if (!file_exists($filePath)) { 99 | $logger->error('Physical file not found for download', ['file_id' => $fileId, 'path' => $filePath]); 100 | header('Location: ./'); 101 | exit; 102 | } 103 | 104 | // ファイルハッシュの検証(ファイル整合性チェック) 105 | if (!empty($tokenData['file_hash'])) { 106 | $currentHash = hash_file('sha256', $filePath); 107 | if ($currentHash !== $tokenData['file_hash']) { 108 | $logger->error('File integrity check failed', [ 109 | 'file_id' => $fileId, 110 | 'expected_hash' => $tokenData['file_hash'], 111 | 'current_hash' => $currentHash 112 | ]); 113 | header('Location: ./'); 114 | exit; 115 | } 116 | } 117 | 118 | // ダウンロード回数の更新 119 | $updateStmt = $db->prepare('UPDATE uploaded SET count = count + 1, updated_at = :updated_at WHERE id = :id'); 120 | $updateStmt->execute([ 121 | 'id' => $fileId, 122 | 'updated_at' => time(), 123 | ]); 124 | 125 | // 使用済みトークンの削除(ワンタイム) 126 | $deleteTokenStmt = $db->prepare('DELETE FROM access_tokens WHERE token = :token'); 127 | $deleteTokenStmt->execute(['token' => $token]); 128 | 129 | // アクセスログの記録 130 | $logger->access($fileId, 'download', 'success'); 131 | 132 | // ファイルダウンロードの実行 133 | $fileSize = filesize($filePath); 134 | 135 | // ヘッダーの設定 136 | header('Content-Type: application/octet-stream'); 137 | header('Content-Disposition: attachment; filename*=UTF-8\'\'' . rawurlencode($fileName)); 138 | header('Content-Length: ' . $fileSize); 139 | header('Cache-Control: no-cache, must-revalidate'); 140 | header('Expires: 0'); 141 | 142 | // 出力バッファのクリア 143 | if (ob_get_level()) { 144 | ob_end_clean(); 145 | } 146 | 147 | // ファイルの出力 148 | $handle = fopen($filePath, 'rb'); 149 | if ($handle) { 150 | while (!feof($handle)) { 151 | echo fread($handle, 8192); 152 | flush(); 153 | } 154 | fclose($handle); 155 | } else { 156 | $logger->error('Failed to open file for download', ['file_id' => $fileId, 'path' => $filePath]); 157 | header('Location: ./'); 158 | } 159 | } catch (Exception $e) { 160 | // 緊急時のエラーハンドリング 161 | $logger->error('Download Error: ' . $e->getMessage(), [ 162 | 'file' => $e->getFile(), 163 | 'line' => $e->getLine(), 164 | 'file_id' => $fileId, 165 | ]); 166 | 167 | header('Location: ./'); 168 | exit; 169 | } 170 | -------------------------------------------------------------------------------- /delete.php: -------------------------------------------------------------------------------- 1 | index(); 24 | 25 | // アプリケーション初期化 26 | require_once $baseDir . '/app/models/init.php'; 27 | 28 | $initInstance = new \PHPUploader\Model\Init($config); 29 | $db = $initInstance -> initialize(); 30 | 31 | // ログ機能の初期化 32 | $logger = new \PHPUploader\Core\Logger($config['logDirectoryPath'], $config['logLevel'], $db); 33 | 34 | // パラメータの取得 35 | $fileId = (int)($_GET['id'] ?? 0); 36 | $token = $_GET['key'] ?? ''; 37 | 38 | if ($fileId <= 0 || empty($token)) { 39 | $logger->warning('Invalid delete parameters', ['file_id' => $fileId, 'token_provided' => !empty($token)]); 40 | header('Location: ./'); 41 | exit; 42 | } 43 | 44 | // トランザクション開始 45 | $db->beginTransaction(); 46 | 47 | try { 48 | // トークンの検証 49 | $tokenStmt = $db->prepare(" 50 | SELECT t.*, u.origin_file_name, u.stored_file_name, u.file_hash 51 | FROM access_tokens t 52 | JOIN uploaded u ON t.file_id = u.id 53 | WHERE t.token = :token AND t.token_type = 'delete' AND t.file_id = :file_id AND t.expires_at > :now 54 | "); 55 | $tokenStmt->execute([ 56 | 'token' => $token, 57 | 'file_id' => $fileId, 58 | 'now' => time() 59 | ]); 60 | 61 | $tokenData = $tokenStmt->fetch(); 62 | 63 | if (!$tokenData) { 64 | $logger->warning( 65 | 'Invalid or expired delete token', 66 | [ 67 | 'file_id' => $fileId, 68 | 'token' => substr($token, 0, 8) . '...' 69 | ] 70 | ); 71 | $db->rollBack(); 72 | header('Location: ./'); 73 | exit; 74 | } 75 | 76 | // IPアドレスの検証(設定で有効な場合) 77 | if ($config['security']['logIpAddress'] && !empty($tokenData['ip_address'])) { 78 | $currentIP = $_SERVER['REMOTE_ADDR'] ?? ''; 79 | if ($currentIP !== $tokenData['ip_address']) { 80 | $logger->warning('IP address mismatch for delete', [ 81 | 'file_id' => $fileId, 82 | 'token_ip' => $tokenData['ip_address'], 83 | 'current_ip' => $currentIP, 84 | ]); 85 | // IPアドレスが異なる場合は警告ログのみで、削除は継続 86 | } 87 | } 88 | 89 | // ファイルパスの生成(ハッシュ化されたファイル名または旧形式に対応) 90 | $fileName = $tokenData['origin_file_name']; 91 | 92 | if (!empty($tokenData['stored_file_name'])) { 93 | // 新形式(ハッシュ化されたファイル名) 94 | $filePath = $config['dataDirectoryPath'] . '/' . $tokenData['stored_file_name']; 95 | } else { 96 | // 旧形式(互換性のため) 97 | $fileExtension = pathinfo($fileName, PATHINFO_EXTENSION); 98 | $filePath = $config['dataDirectoryPath'] . '/file_' . $fileId . '.' . $fileExtension; 99 | } 100 | 101 | // 物理ファイルの削除 102 | $fileDeleted = false; 103 | if (file_exists($filePath)) { 104 | // ファイルハッシュの検証(ファイル整合性チェック) 105 | if (!empty($tokenData['file_hash'])) { 106 | $currentHash = hash_file('sha256', $filePath); 107 | if ($currentHash !== $tokenData['file_hash']) { 108 | $logger->warning('File integrity check failed during delete', [ 109 | 'file_id' => $fileId, 110 | 'expected_hash' => $tokenData['file_hash'], 111 | 'current_hash' => $currentHash 112 | ]); 113 | // 整合性チェックに失敗した場合でも削除は継続(ファイルが破損している可能性) 114 | } 115 | } 116 | 117 | if (unlink($filePath)) { 118 | $fileDeleted = true; 119 | $logger->info('Physical file deleted', ['file_id' => $fileId, 'path' => $filePath]); 120 | } else { 121 | $logger->error('Failed to delete physical file', ['file_id' => $fileId, 'path' => $filePath]); 122 | } 123 | } else { 124 | $logger->warning('Physical file not found for delete', ['file_id' => $fileId, 'path' => $filePath]); 125 | $fileDeleted = true; // ファイルが存在しない場合は削除済みとみなす 126 | } 127 | 128 | // データベースからファイル情報を削除 129 | $deleteStmt = $db->prepare('DELETE FROM uploaded WHERE id = :id'); 130 | if (!$deleteStmt->execute(['id' => $fileId])) { 131 | throw new Exception('Failed to delete file record from database'); 132 | } 133 | 134 | // 関連するアクセストークンを削除 135 | $deleteTokensStmt = $db->prepare('DELETE FROM access_tokens WHERE file_id = :file_id'); 136 | $deleteTokensStmt->execute(['file_id' => $fileId]); 137 | 138 | // トランザクションコミット 139 | $db->commit(); 140 | 141 | // アクセスログの記録 142 | $logger->access($fileId, 'delete', 'success'); 143 | 144 | // 成功時のリダイレクト 145 | header('Location: ./?deleted=success'); 146 | exit; 147 | } catch (Exception $e) { 148 | // トランザクションロールバック 149 | $db->rollBack(); 150 | throw $e; 151 | } 152 | } catch (Exception $e) { 153 | // 緊急時のエラーハンドリング 154 | $logger->error('Delete Error: ' . $e->getMessage(), [ 155 | 'file' => $e->getFile(), 156 | 'line' => $e->getLine(), 157 | 'file_id' => $fileId, 158 | ]); 159 | 160 | header('Location: ./?deleted=error'); 161 | exit; 162 | } 163 | -------------------------------------------------------------------------------- /src/Core/Logger.php: -------------------------------------------------------------------------------- 1 | 0, 29 | self::LOG_ALERT => 1, 30 | self::LOG_CRITICAL => 2, 31 | self::LOG_ERROR => 3, 32 | self::LOG_WARNING => 4, 33 | self::LOG_NOTICE => 5, 34 | self::LOG_INFO => 6, 35 | self::LOG_DEBUG => 7, 36 | ]; 37 | 38 | public function __construct(string $logDirectory, string $logLevel = self::LOG_INFO, \PDO $db = null) 39 | { 40 | $baseDir = dirname(dirname(__DIR__)); // アプリケーションルートディレクトリ 41 | require_once $baseDir . '/src/Core/SecurityUtils.php'; 42 | 43 | $this->logDirectory = rtrim($logDirectory, '/'); 44 | $this->logLevel = $logLevel; 45 | $this->db = $db; 46 | 47 | // ログディレクトリが存在しない場合は作成 48 | if (!is_dir($this->logDirectory)) { 49 | mkdir($this->logDirectory, 0755, true); 50 | } 51 | } 52 | 53 | /** 54 | * ログレベルに応じてログを記録するか判定 55 | */ 56 | private function shouldLog(string $level): bool 57 | { 58 | $currentLevel = self::LOG_LEVELS[$this->logLevel] ?? 6; 59 | $messageLevel = self::LOG_LEVELS[$level] ?? 6; 60 | 61 | return $messageLevel <= $currentLevel; 62 | } 63 | 64 | /** 65 | * 汎用ログメソッド 66 | */ 67 | public function log(string $level, string $message, array $context = []): void 68 | { 69 | if (!$this->shouldLog($level)) { 70 | return; 71 | } 72 | 73 | $timestamp = date('Y-m-d H:i:s'); 74 | $contextStr = !empty($context) ? ' | Context: ' . json_encode($context, JSON_UNESCAPED_UNICODE) : ''; 75 | $logMessage = "[{$timestamp}] [{$level}] {$message}{$contextStr}" . PHP_EOL; 76 | 77 | // ファイルログ 78 | $logFile = $this->logDirectory . '/' . date('Y-m-d') . '.log'; 79 | file_put_contents($logFile, $logMessage, FILE_APPEND | LOCK_EX); 80 | 81 | // データベースログ(利用可能な場合) 82 | if ($this->db) { 83 | try { 84 | $stmt = $this->db->prepare(' 85 | INSERT INTO access_logs (file_id, action, ip_address, user_agent, status, created_at) 86 | VALUES (?, ?, ?, ?, ?, ?) 87 | '); 88 | 89 | if ($stmt) { 90 | $stmt->execute([ 91 | null, // file_id は通常のログでは null 92 | $level, 93 | SecurityUtils::getClientIP(), 94 | SecurityUtils::getUserAgent(), 95 | 'logged', 96 | time(), 97 | ]); 98 | } 99 | } catch (\Exception $e) { 100 | // データベースログに失敗してもファイルログは残す 101 | error_log('Database logging failed: ' . $e->getMessage()); 102 | } 103 | } 104 | } 105 | 106 | // 各ログレベルのショートカットメソッド 107 | public function emergency(string $message, array $context = []): void 108 | { 109 | $this->log(self::LOG_EMERGENCY, $message, $context); 110 | } 111 | 112 | public function alert(string $message, array $context = []): void 113 | { 114 | $this->log(self::LOG_ALERT, $message, $context); 115 | } 116 | 117 | public function critical(string $message, array $context = []): void 118 | { 119 | $this->log(self::LOG_CRITICAL, $message, $context); 120 | } 121 | 122 | public function error(string $message, array $context = []): void 123 | { 124 | $this->log(self::LOG_ERROR, $message, $context); 125 | } 126 | 127 | public function warning(string $message, array $context = []): void 128 | { 129 | $this->log(self::LOG_WARNING, $message, $context); 130 | } 131 | 132 | public function notice(string $message, array $context = []): void 133 | { 134 | $this->log(self::LOG_NOTICE, $message, $context); 135 | } 136 | 137 | public function info(string $message, array $context = []): void 138 | { 139 | $this->log(self::LOG_INFO, $message, $context); 140 | } 141 | 142 | public function debug(string $message, array $context = []): void 143 | { 144 | $this->log(self::LOG_DEBUG, $message, $context); 145 | } 146 | 147 | /** 148 | * アクセスログ専用メソッド 149 | */ 150 | public function access(int|string|null $fileId, string $action, string $status, array $context = []): void 151 | { 152 | $message = "Access: {$action} | Status: {$status}"; 153 | if ($fileId) { 154 | $message .= " | File ID: {$fileId}"; 155 | } 156 | 157 | $accessContext = array_merge($context, [ 158 | 'ip' => SecurityUtils::getClientIP(), 159 | 'user_agent' => SecurityUtils::getUserAgent(), 160 | 'referer' => SecurityUtils::getReferer(), 161 | 'request_uri' => $_SERVER['REQUEST_URI'] ?? '', 162 | 'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', 163 | ]); 164 | 165 | // ファイルログ 166 | $this->info($message, $accessContext); 167 | 168 | // データベースログ(access_logs テーブル用) 169 | if ($this->db) { 170 | try { 171 | $stmt = $this->db->prepare(' 172 | INSERT INTO access_logs (file_id, action, ip_address, user_agent, status, created_at) 173 | VALUES (?, ?, ?, ?, ?, ?) 174 | '); 175 | 176 | if ($stmt) { 177 | $stmt->execute([ 178 | $fileId ? (int)$fileId : null, 179 | $action, 180 | SecurityUtils::getClientIP(), 181 | SecurityUtils::getUserAgent(), 182 | $status, 183 | time(), 184 | ]); 185 | } 186 | } catch (\Exception $e) { 187 | error_log('Access log to database failed: ' . $e->getMessage()); 188 | } 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/Core/SecurityUtils.php: -------------------------------------------------------------------------------- 1 | $maxSize) { 83 | $errors[] = 'ファイルサイズが制限を超えています。最大: ' . ($config['maxFileSize'] ?? 10) . 'MB'; 84 | } 85 | 86 | // 拡張子のチェック 87 | $allowedExtensions = $config['extension'] ?? [ 88 | 'jpg', 89 | 'png', 90 | 'gif', 91 | 'pdf', 92 | 'txt', 93 | ]; 94 | $fileExtension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); 95 | 96 | if (!in_array($fileExtension, $allowedExtensions, true)) { 97 | $errors[] = '許可されていない拡張子です。許可されている拡張子: ' . implode(', ', $allowedExtensions); 98 | } 99 | 100 | // MIMEタイプの基本チェック 101 | $finfo = finfo_open(FILEINFO_MIME_TYPE); 102 | $mimeType = finfo_file($finfo, $file['tmp_name']); 103 | finfo_close($finfo); 104 | 105 | $allowedMimeTypes = [ 106 | 'jpg' => ['image/jpeg'], 107 | 'jpeg' => ['image/jpeg'], 108 | 'png' => ['image/png'], 109 | 'gif' => ['image/gif'], 110 | 'pdf' => ['application/pdf'], 111 | 'txt' => ['text/plain'], 112 | 'zip' => ['application/zip'], 113 | 'doc' => ['application/msword'], 114 | 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], 115 | ]; 116 | 117 | if (isset($allowedMimeTypes[$fileExtension])) { 118 | if (!in_array($mimeType, $allowedMimeTypes[$fileExtension], true)) { 119 | $errors[] = 'ファイルの内容が拡張子と一致しません。'; 120 | } 121 | } 122 | 123 | return $errors; 124 | } 125 | 126 | /** 127 | * 安全なファイルパスを生成 128 | */ 129 | public static function generateSafeFilePath(string $uploadDir, string $filename): string 130 | { 131 | // ディレクトリトラバーサル攻撃を防ぐ 132 | $filename = basename($filename); 133 | $filename = self::sanitizeFilename($filename); 134 | 135 | // ファイル名が空の場合はランダムな名前を生成 136 | if (empty($filename)) { 137 | $filename = 'file_' . time() . '_' . bin2hex(random_bytes(4)); 138 | } 139 | 140 | // 重複を避けるためにタイムスタンプを追加 141 | $pathInfo = pathinfo($filename); 142 | $baseName = $pathInfo['filename'] ?? 'file'; 143 | $extension = isset($pathInfo['extension']) ? '.' . $pathInfo['extension'] : ''; 144 | 145 | $counter = 0; 146 | do { 147 | if ($counter === 0) { 148 | $newFilename = $baseName . $extension; 149 | } else { 150 | $newFilename = $baseName . '_' . $counter . $extension; 151 | } 152 | $fullPath = rtrim($uploadDir, '/') . '/' . $newFilename; 153 | $counter++; 154 | } while (file_exists($fullPath) && $counter < 1000); 155 | 156 | return $fullPath; 157 | } 158 | 159 | /** 160 | * IPアドレスを取得(プロキシ対応) 161 | */ 162 | public static function getClientIP(): string 163 | { 164 | $headers = [ 165 | 'HTTP_CF_CONNECTING_IP', // Cloudflare 166 | 'HTTP_CLIENT_IP', // プロキシ 167 | 'HTTP_X_FORWARDED_FOR', // ロードバランサー/プロキシ 168 | 'HTTP_X_FORWARDED', // プロキシ 169 | 'HTTP_X_CLUSTER_CLIENT_IP', // クラスター 170 | 'HTTP_FORWARDED_FOR', // プロキシ 171 | 'HTTP_FORWARDED', // プロキシ 172 | 'REMOTE_ADDR', // 標準 173 | ]; 174 | 175 | foreach ($headers as $header) { 176 | if (!empty($_SERVER[$header])) { 177 | $ips = explode(',', $_SERVER[$header]); 178 | $ip = trim($ips[0]); 179 | 180 | // プライベートIPと予約済みIPを除外 181 | if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { 182 | return $ip; 183 | } 184 | } 185 | } 186 | 187 | return $_SERVER['REMOTE_ADDR'] ?? 'unknown'; 188 | } 189 | 190 | /** 191 | * ユーザーエージェントを安全に取得 192 | */ 193 | public static function getUserAgent(): string 194 | { 195 | $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'; 196 | return htmlspecialchars($userAgent, ENT_QUOTES, 'UTF-8'); 197 | } 198 | 199 | /** 200 | * リファラーを安全に取得 201 | */ 202 | public static function getReferer(): string 203 | { 204 | $referer = $_SERVER['HTTP_REFERER'] ?? ''; 205 | return htmlspecialchars($referer, ENT_QUOTES, 'UTF-8'); 206 | } 207 | 208 | /** 209 | * 安全な文字列エスケープ 210 | */ 211 | public static function escapeHtml(?string $string): string 212 | { 213 | if ($string === null) { 214 | return ''; 215 | } 216 | return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); 217 | } 218 | 219 | /** 220 | * ランダムなトークンを生成 221 | */ 222 | public static function generateRandomToken(int $length = 32): string 223 | { 224 | return bin2hex(random_bytes($length)); 225 | } 226 | 227 | /** 228 | * パスワードハッシュを生成(Argon2ID) 229 | */ 230 | public static function hashPassword(string $password): string 231 | { 232 | return password_hash($password, PASSWORD_ARGON2ID, [ 233 | 'memory_cost' => 65536, // 64 MB 234 | 'time_cost' => 4, // 4 iterations 235 | 'threads' => 3, // 3 threads 236 | ]); 237 | } 238 | 239 | /** 240 | * パスワードハッシュを検証 241 | */ 242 | public static function verifyPassword(string $password, string $hash): bool 243 | { 244 | return password_verify($password, $hash); 245 | } 246 | 247 | /** 248 | * セキュアなファイル名を生成(ハッシュ化) 249 | */ 250 | public static function generateSecureFileName(int $fileId, string $originalName): string 251 | { 252 | // ファイルIDと元のファイル名、現在時刻を組み合わせてハッシュ化 253 | $data = $fileId . '_' . $originalName . '_' . time() . '_' . bin2hex(random_bytes(8)); 254 | return hash('sha256', $data); 255 | } 256 | 257 | /** 258 | * ハッシュ化されたファイル名から拡張子付きのフルファイル名を生成 259 | */ 260 | public static function generateStoredFileName(string $hashedName, string $extension): string 261 | { 262 | return $hashedName . '.' . strtolower($extension); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /app/models/init.php: -------------------------------------------------------------------------------- 1 | config = $config; 22 | } 23 | 24 | /** 25 | * 初期化メイン処理 26 | */ 27 | public function initialize(): \PDO 28 | { 29 | $this->validateConfig(); 30 | $this->createDirectories(); 31 | $this->initializeDatabase(); 32 | $this->setupDatabase(); 33 | 34 | return $this->db; 35 | } 36 | 37 | /** 38 | * 設定ファイルの検証 39 | */ 40 | private function validateConfig(): void 41 | { 42 | // セキュリティ設定の検証 43 | if ($this->config['master'] === 'CHANGE_THIS_MASTER_KEY') { 44 | $this->throwError('マスターキーが設定されていません。config.phpを確認してください。'); 45 | } 46 | 47 | if ($this->config['key'] === 'CHANGE_THIS_ENCRYPTION_KEY') { 48 | $this->throwError('暗号化キーが設定されていません。config.phpを確認してください。'); 49 | } 50 | 51 | if ($this->config['sessionSalt'] === 'CHANGE_THIS_sessionSalt') { 52 | $this->throwError('セッションソルトが設定されていません。config.phpを確認してください。'); 53 | } 54 | 55 | // 必要な拡張モジュールの確認 56 | $requiredExtensions = [ 57 | 'pdo', 58 | 'sqlite3', 59 | 'openssl', 60 | 'json', 61 | ]; 62 | foreach ($requiredExtensions as $ext) { 63 | if (!extension_loaded($ext)) { 64 | $this->throwError("必要なPHP拡張モジュール '{$ext}' がロードされていません。"); 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * 必要なディレクトリの作成 71 | */ 72 | private function createDirectories(): void 73 | { 74 | $directories = [ 75 | $this->config['dbDirectoryPath'], 76 | $this->config['dataDirectoryPath'], 77 | $this->config['logDirectoryPath'], 78 | ]; 79 | 80 | foreach ($directories as $dir) { 81 | if (!file_exists($dir)) { 82 | if (!mkdir($dir, 0755, true)) { 83 | $this->throwError("ディレクトリ '{$dir}' の作成に失敗しました。"); 84 | } 85 | } 86 | 87 | // 書き込み権限の確認 88 | if (!is_writable($dir)) { 89 | $this->throwError("ディレクトリ '{$dir}' に書き込み権限がありません。"); 90 | } 91 | } 92 | } 93 | 94 | /** 95 | * データベース接続の初期化 96 | */ 97 | private function initializeDatabase(): void 98 | { 99 | try { 100 | $dsn = 'sqlite:' . $this->config['dbDirectoryPath'] . '/uploader.db'; 101 | $this->db = new \PDO($dsn); 102 | 103 | // エラーモードを例外に設定 104 | $this->db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); 105 | 106 | // デフォルトのフェッチモードを連想配列形式に設定 107 | $this->db->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC); 108 | } catch (\PDOException $e) { 109 | $this->throwError('データベースの接続に失敗しました: ' . $e->getMessage()); 110 | } 111 | } 112 | 113 | /** 114 | * データベーステーブルの作成・更新 115 | */ 116 | private function setupDatabase(): void 117 | { 118 | try { 119 | // メインテーブルの作成 120 | $query = " 121 | CREATE TABLE IF NOT EXISTS uploaded( 122 | id INTEGER PRIMARY KEY AUTOINCREMENT, 123 | origin_file_name text NOT NULL, 124 | stored_file_name text, 125 | comment text, 126 | size INTEGER NOT NULL, 127 | count INTEGER DEFAULT 0, 128 | input_date INTEGER NOT NULL, 129 | dl_key_hash text, 130 | del_key_hash text, 131 | file_hash text, 132 | ip_address text, 133 | created_at INTEGER DEFAULT (strftime('%s', 'now')), 134 | updated_at INTEGER DEFAULT (strftime('%s', 'now')) 135 | )"; 136 | 137 | $this->db->exec($query); 138 | 139 | // トークンテーブルの作成(ワンタイムトークン用) 140 | $tokenQuery = " 141 | CREATE TABLE IF NOT EXISTS access_tokens( 142 | id INTEGER PRIMARY KEY AUTOINCREMENT, 143 | file_id INTEGER NOT NULL, 144 | token text NOT NULL UNIQUE, 145 | token_type text NOT NULL, -- 'download' or 'delete' 146 | expires_at INTEGER NOT NULL, 147 | ip_address text, 148 | created_at INTEGER DEFAULT (strftime('%s', 'now')), 149 | FOREIGN KEY (file_id) REFERENCES uploaded (id) ON DELETE CASCADE 150 | )"; 151 | 152 | $this->db->exec($tokenQuery); 153 | 154 | // ログテーブルの作成 155 | $logQuery = " 156 | CREATE TABLE IF NOT EXISTS access_logs( 157 | id INTEGER PRIMARY KEY AUTOINCREMENT, 158 | file_id INTEGER, 159 | action text NOT NULL, -- 'upload', 'download', 'delete', 'view' 160 | ip_address text, 161 | user_agent text, 162 | status text DEFAULT 'success', -- 'success', 'error', 'denied' 163 | error_message text, 164 | created_at INTEGER DEFAULT (strftime('%s', 'now')) 165 | )"; 166 | 167 | $this->db->exec($logQuery); 168 | 169 | // インデックスの作成 170 | $this->createIndexes(); 171 | 172 | // 既存データの移行(必要に応じて) 173 | $this->migrateExistingData(); 174 | } catch (\PDOException $e) { 175 | $this->throwError('データベースの初期化に失敗しました: ' . $e->getMessage()); 176 | } 177 | } 178 | 179 | /** 180 | * データベースインデックスの作成 181 | */ 182 | private function createIndexes(): void 183 | { 184 | $indexes = [ 185 | 'CREATE INDEX IF NOT EXISTS idx_uploaded_input_date ON uploaded(input_date)', 186 | 'CREATE INDEX IF NOT EXISTS idx_uploaded_file_hash ON uploaded(file_hash)', 187 | 'CREATE INDEX IF NOT EXISTS idx_tokens_expires_at ON access_tokens(expires_at)', 188 | 'CREATE INDEX IF NOT EXISTS idx_tokens_file_id ON access_tokens(file_id)', 189 | 'CREATE INDEX IF NOT EXISTS idx_logs_created_at ON access_logs(created_at)', 190 | 'CREATE INDEX IF NOT EXISTS idx_logs_file_id ON access_logs(file_id)', 191 | ]; 192 | 193 | foreach ($indexes as $indexQuery) { 194 | $this->db->exec($indexQuery); 195 | } 196 | } 197 | 198 | /** 199 | * 既存データの移行処理 200 | */ 201 | private function migrateExistingData(): void 202 | { 203 | // uploaded テーブルに新しいカラムが存在するかチェック 204 | $columns = $this->db->query('PRAGMA table_info(uploaded)')->fetchAll(); 205 | $columnNames = array_column($columns, 'name'); 206 | 207 | // 新しいカラムの追加 208 | $newColumns = [ 209 | 'stored_file_name' => 'ALTER TABLE uploaded ADD COLUMN stored_file_name text', 210 | 'file_hash' => 'ALTER TABLE uploaded ADD COLUMN file_hash text', 211 | 'ip_address' => 'ALTER TABLE uploaded ADD COLUMN ip_address text', 212 | 'created_at' => 'ALTER TABLE uploaded ADD COLUMN created_at INTEGER DEFAULT (strftime(\'%s\', \'now\'))', 213 | 'updated_at' => 'ALTER TABLE uploaded ADD COLUMN updated_at INTEGER DEFAULT (strftime(\'%s\', \'now\'))', 214 | ]; 215 | 216 | foreach ($newColumns as $columnName => $alterQuery) { 217 | if (!in_array($columnName, $columnNames)) { 218 | try { 219 | $this->db->exec($alterQuery); 220 | } catch (\PDOException $e) { 221 | // カラム追加に失敗した場合はログに記録するが処理は続行 222 | error_log("Column migration failed for {$columnName}: " . $e->getMessage()); 223 | } 224 | } 225 | } 226 | } 227 | 228 | /** 229 | * エラー処理 230 | */ 231 | private function throwError(string $message): void 232 | { 233 | // ログディレクトリが存在する場合はエラーログを記録 234 | if (isset($this->config['logDirectoryPath']) && is_dir($this->config['logDirectoryPath'])) { 235 | $logFile = $this->config['logDirectoryPath'] . '/error.log'; 236 | $logMessage = date('Y-m-d H:i:s') . ' [ERROR] ' . $message . PHP_EOL; 237 | file_put_contents($logFile, $logMessage, FILE_APPEND | LOCK_EX); 238 | } 239 | 240 | // エラーページの表示 241 | $error = '500 - ' . $message; 242 | include('./app/views/header.php'); 243 | include('./app/views/error.php'); 244 | include('./app/views/footer.php'); 245 | exit; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /app/api/upload.php: -------------------------------------------------------------------------------- 1 | index(); 39 | 40 | // アプリケーション初期化 41 | require_once $baseDir . '/app/models/init.php'; 42 | 43 | $initInstance = new \PHPUploader\Model\Init($config); 44 | $db = $initInstance -> initialize(); 45 | 46 | // ログとレスポンスハンドラーの初期化 47 | $logger = new \PHPUploader\Core\Logger($config['logDirectoryPath'], $config['logLevel'], $db); 48 | $responseHandler = new \PHPUploader\Core\ResponseHandler($logger); 49 | 50 | // リクエストメソッドの確認 51 | if ($_SERVER['REQUEST_METHOD'] !== 'POST') { 52 | $responseHandler->error('無効なリクエストメソッドです。', [], 405); 53 | } 54 | 55 | // ファイルがアップロードされているかチェック 56 | if (!isset($_FILES['file'])) { 57 | $responseHandler->error('ファイルが選択されていません。', [], 400); 58 | } 59 | 60 | // CSRFトークンの検証 61 | if (!\PHPUploader\Core\SecurityUtils::validateCSRFToken($_POST['csrf_token'] ?? null)) { 62 | $logger->warning('CSRF token validation failed', ['ip' => $_SERVER['REMOTE_ADDR'] ?? '']); 63 | $responseHandler->error('無効なリクエストです。ページを再読み込みしてください。', [], 403); 64 | } 65 | 66 | // ファイルアップロードエラーチェック 67 | $uploadErrors = []; 68 | if (!isset($_FILES['file'])) { 69 | $uploadErrors[] = 'ファイルが選択されていません。'; 70 | } else { 71 | switch ($_FILES['file']['error']) { 72 | case UPLOAD_ERR_OK: 73 | break; 74 | case UPLOAD_ERR_INI_SIZE: 75 | $uploadErrors[] = 'アップロードされたファイルが大きすぎます。(' . ini_get('upload_max_filesize') . '以下)'; 76 | break; 77 | case UPLOAD_ERR_FORM_SIZE: 78 | $uploadErrors[] = 'アップロードされたファイルが大きすぎます。(' . ($_POST['maxFileSize'] / 1024) . 'KB以下)'; 79 | break; 80 | case UPLOAD_ERR_PARTIAL: 81 | $uploadErrors[] = 'アップロードが途中で中断されました。もう一度お試しください。'; 82 | break; 83 | case UPLOAD_ERR_NO_FILE: 84 | $uploadErrors[] = 'ファイルが選択されていません。'; 85 | break; 86 | case UPLOAD_ERR_NO_TMP_DIR: 87 | $uploadErrors[] = 'サーバーエラーが発生しました。管理者にお問い合わせください。'; 88 | break; 89 | default: 90 | $uploadErrors[] = 'アップロードに失敗しました。'; 91 | break; 92 | } 93 | } 94 | 95 | if (!empty($uploadErrors)) { 96 | $responseHandler->error('アップロードエラー', $uploadErrors, 400); 97 | } 98 | 99 | // アップロードファイルの検証 100 | if (!is_uploaded_file($_FILES['file']['tmp_name'])) { 101 | $responseHandler->error('不正なファイルアップロードです。', [], 400); 102 | } 103 | 104 | // 入力データの取得とサニタイズ 105 | $fileName = htmlspecialchars($_FILES['file']['name'], ENT_QUOTES, 'UTF-8'); 106 | $comment = htmlspecialchars($_POST['comment'] ?? '', ENT_QUOTES, 'UTF-8'); 107 | $dlKey = $_POST['dlkey'] ?? ''; 108 | $delKey = $_POST['delkey'] ?? ''; 109 | $fileSize = filesize($_FILES['file']['tmp_name']); 110 | $fileTmpPath = $_FILES['file']['tmp_name']; 111 | 112 | // バリデーション 113 | $validationErrors = []; 114 | 115 | // ファイルサイズチェック 116 | if ($fileSize > $config['maxFileSize'] * 1024 * 1024) { 117 | $validationErrors[] = "ファイルサイズが上限({$config['maxFileSize']}MB)を超えています。"; 118 | } 119 | 120 | // 拡張子チェック 121 | $fileExtension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); 122 | if (!in_array($fileExtension, $config['extension'])) { 123 | $validationErrors[] = '許可されていない拡張子です。(' . implode(', ', $config['extension']) . 'のみ)'; 124 | } 125 | 126 | // コメント文字数チェック 127 | if (mb_strlen($comment) > $config['maxComment']) { 128 | $validationErrors[] = "コメントが長すぎます。({$config['maxComment']}文字以下)"; 129 | } 130 | 131 | // キーの長さチェック 132 | if (!empty($dlKey) && mb_strlen($dlKey) < $config['security']['minKeyLength']) { 133 | $validationErrors[] = "ダウンロードキーは{$config['security']['minKeyLength']}文字以上で設定してください。"; 134 | } 135 | 136 | if (!empty($delKey) && mb_strlen($delKey) < $config['security']['minKeyLength']) { 137 | $validationErrors[] = "削除キーは{$config['security']['minKeyLength']}文字以上で設定してください。"; 138 | } 139 | 140 | if (!empty($validationErrors)) { 141 | $responseHandler->error('バリデーションエラー', $validationErrors, 400); 142 | } 143 | 144 | // ファイル数制限チェックと古いファイルの削除 145 | $fileCountStmt = $db->prepare('SELECT COUNT(id) as count, MIN(id) as min_id FROM uploaded'); 146 | $fileCountStmt->execute(); 147 | $countResult = $fileCountStmt->fetch(); 148 | 149 | if ($countResult['count'] >= $config['saveMaxFiles']) { 150 | // 古いファイルを削除 151 | $oldFileStmt = $db->prepare('SELECT id, origin_file_name, stored_file_name FROM uploaded WHERE id = :id'); 152 | $oldFileStmt->execute(['id' => $countResult['min_id']]); 153 | $oldFile = $oldFileStmt->fetch(); 154 | 155 | if ($oldFile) { 156 | // 物理ファイルの削除(ハッシュ化されたファイル名または旧形式に対応) 157 | if (!empty($oldFile['stored_file_name'])) { 158 | // 新形式(ハッシュ化されたファイル名) 159 | $oldFilePath = $config['dataDirectoryPath'] . '/' . $oldFile['stored_file_name']; 160 | } else { 161 | // 旧形式(互換性のため) 162 | $oldFilePath = $config['dataDirectoryPath'] . '/file_' . $oldFile['id'] . 163 | '.' . pathinfo($oldFile['origin_file_name'], PATHINFO_EXTENSION); 164 | } 165 | 166 | if (file_exists($oldFilePath)) { 167 | unlink($oldFilePath); 168 | } 169 | 170 | // データベースから削除 171 | $deleteStmt = $db->prepare('DELETE FROM uploaded WHERE id = :id'); 172 | $deleteStmt->execute(['id' => $oldFile['id']]); 173 | 174 | $logger->info('Old file deleted due to storage limit', ['deleted_file_id' => $oldFile['id']]); 175 | } 176 | } 177 | 178 | // ファイルハッシュの生成 179 | $fileHash = hash_file('sha256', $fileTmpPath); 180 | 181 | // 認証キーのハッシュ化(空の場合はnull) 182 | $dlKeyHash = 183 | (!empty($dlKey) && trim($dlKey) !== '') ? \PHPUploader\Core\SecurityUtils::hashPassword($dlKey) : null; 184 | $delKeyHash = 185 | (!empty($delKey) && trim($delKey) !== '') ? \PHPUploader\Core\SecurityUtils::hashPassword($delKey) : null; 186 | 187 | // まず仮のデータベース登録(stored_file_nameは後で更新) 188 | $insertStmt = $db->prepare(' 189 | INSERT INTO uploaded ( 190 | origin_file_name, comment, size, count, input_date, 191 | dl_key_hash, del_key_hash, file_hash, ip_address 192 | ) VALUES ( 193 | :origin_file_name, :comment, :size, :count, :input_date, 194 | :dl_key_hash, :del_key_hash, :file_hash, :ip_address 195 | ) 196 | '); 197 | 198 | $insertData = [ 199 | 'origin_file_name' => $fileName, 200 | 'comment' => $comment, 201 | 'size' => $fileSize, 202 | 'count' => 0, 203 | 'input_date' => time(), 204 | 'dl_key_hash' => $dlKeyHash, 205 | 'del_key_hash' => $delKeyHash, 206 | 'file_hash' => $fileHash, 207 | 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, 208 | ]; 209 | 210 | if (!$insertStmt->execute($insertData)) { 211 | $errorInfo = $insertStmt->errorInfo(); 212 | error_log('Database insert failed: ' . print_r($errorInfo, true)); 213 | $responseHandler->error('データベースへの保存に失敗しました。', [], 500); 214 | } 215 | 216 | $fileId = (int)$db->lastInsertId(); 217 | 218 | // セキュアなファイル名の生成(ハッシュ化) 219 | $hashedFileName = \PHPUploader\Core\SecurityUtils::generateSecureFileName($fileId, $fileName); 220 | $storedFileName = \PHPUploader\Core\SecurityUtils::generateStoredFileName($hashedFileName, $fileExtension); 221 | $saveFilePath = $config['dataDirectoryPath'] . '/' . $storedFileName; 222 | 223 | // ファイル保存 224 | if (!move_uploaded_file($fileTmpPath, $saveFilePath)) { 225 | // データベースからも削除 226 | $db->prepare('DELETE FROM uploaded WHERE id = :id')->execute(['id' => $fileId]); 227 | $responseHandler->error('ファイルの保存に失敗しました。', [], 500); 228 | } 229 | 230 | // データベースにハッシュ化されたファイル名を記録 231 | $updateStmt = $db->prepare('UPDATE uploaded SET stored_file_name = :stored_file_name WHERE id = :id'); 232 | if (!$updateStmt->execute(['stored_file_name' => $storedFileName, 'id' => $fileId])) { 233 | // ファイルを削除してデータベースからも削除 234 | if (file_exists($saveFilePath)) { 235 | unlink($saveFilePath); 236 | } 237 | $db->prepare('DELETE FROM uploaded WHERE id = :id')->execute(['id' => $fileId]); 238 | $responseHandler->error('ファイル情報の更新に失敗しました。', [], 500); 239 | } 240 | 241 | // アクセスログの記録 242 | $logger->access($fileId, 'upload', 'success'); 243 | 244 | // 成功レスポンス 245 | $responseHandler->success('ファイルのアップロードが完了しました。', [ 246 | 'file_id' => $fileId, 247 | 'file_name' => $fileName, 248 | 'file_size' => $fileSize, 249 | ]); 250 | } catch (Exception $e) { 251 | // 出力バッファをクリア 252 | if (ob_get_level()) { 253 | ob_clean(); 254 | } 255 | 256 | // 緊急時のエラーハンドリング 257 | $logger->error('Upload API Error: ' . $e->getMessage(), [ 258 | 'file' => $e->getFile(), 259 | 'line' => $e->getLine(), 260 | ]); 261 | 262 | $responseHandler->error('システムエラーが発生しました。', [], 500); 263 | } 264 | -------------------------------------------------------------------------------- /.github/workflows/wc-php-test.yaml: -------------------------------------------------------------------------------- 1 | name: PHP Test 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | APP_ID: 7 | required: true 8 | PRIVATE_KEY: 9 | required: true 10 | 11 | permissions: 12 | id-token: write 13 | contents: read 14 | pull-requests: write 15 | issues: write 16 | 17 | jobs: 18 | security-scan: 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | php-version: 25 | - '8.1' # minimum supported version 26 | # renovate: php-version 27 | - '8.3' 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 32 | 33 | - uses: aquaproj/aqua-installer@11dd79b4e498d471a9385aa9fb7f62bb5f52a73c # v4.0.4 34 | with: 35 | aqua_version: v2.55.3 36 | 37 | - id: app-token 38 | uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 39 | with: 40 | app-id: ${{ secrets.APP_ID }} 41 | private-key: ${{ secrets.PRIVATE_KEY }} 42 | 43 | - name: Setup PHP 44 | uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 45 | with: 46 | php-version: ${{ matrix.php-version }} 47 | extensions: pdo, pdo_sqlite, sqlite3 48 | tools: composer 49 | 50 | - name: Install dependencies 51 | run: composer install --prefer-dist --no-progress 52 | 53 | - name: Security audit 54 | run: | 55 | # Composerセキュリティ監査 56 | composer audit 57 | 58 | - name: Check for sensitive files 59 | run: | 60 | # 機密ファイルがコミットされていないかチェック 61 | if [ -f "config/config.php" ]; then 62 | echo "Error: config.php should not be committed" 63 | github-comment post -k common-error -var title:"Check Sensitive Files Exists" -var body:"Error: config.php should not be committed" 64 | exit 1 65 | fi 66 | 67 | # .envファイルのチェック 68 | if [ -f ".env" ]; then 69 | echo "Error: .env should not be committed" 70 | github-comment post -k common-error -var title:"Check Sensitive Files Exists" -var body:"Error: .env should not be committed" 71 | exit 1 72 | fi 73 | 74 | echo "✅ No sensitive files found" 75 | env: 76 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 77 | 78 | 79 | lint-and-test: 80 | runs-on: ubuntu-latest 81 | 82 | strategy: 83 | fail-fast: false 84 | matrix: 85 | php-version: 86 | - '8.1' # minimum supported version 87 | # renovate: php-version 88 | - '8.3' 89 | 90 | steps: 91 | - name: Checkout code 92 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 93 | 94 | - uses: aquaproj/aqua-installer@11dd79b4e498d471a9385aa9fb7f62bb5f52a73c # v4.0.4 95 | with: 96 | aqua_version: v2.55.3 97 | 98 | - id: app-token 99 | uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 100 | with: 101 | app-id: ${{ secrets.APP_ID }} 102 | private-key: ${{ secrets.PRIVATE_KEY }} 103 | 104 | - name: Display PHP version info 105 | run: | 106 | echo "🐘 Testing PHP ${{ matrix.php-version }}" 107 | 108 | - name: Setup PHP 109 | uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 110 | with: 111 | php-version: ${{ matrix.php-version }} 112 | extensions: pdo, pdo_sqlite, sqlite3 113 | tools: composer 114 | coverage: none 115 | ini-values: error_reporting=E_ALL&~E_DEPRECATED&~E_STRICT 116 | 117 | - name: Validate composer.json 118 | run: | 119 | echo "🔍 composer.json検証中..." 120 | 121 | # まず基本的なJSONの妥当性をチェック 122 | if ! php -r "json_decode(file_get_contents('composer.json'), true); if (json_last_error() !== JSON_ERROR_NONE) exit(1);"; then 123 | echo "❌ composer.json contains invalid JSON" 124 | echo "🔧 composer.jsonの構文を確認してください" 125 | github-comment post -k common-error -var title:"composer.json contains invalid JSON (PHP: ${{ matrix.php-version }})" -var body:"composer.jsonの構文を確認してください" 126 | exit 1 127 | fi 128 | 129 | # Composer validation実行 130 | echo "📋 Composer validation実行中..." 131 | if ! composer validate --no-check-publish; then 132 | echo "❌ composer.json validation failed" 133 | echo "🔍 詳細な診断情報:" 134 | composer validate --verbose 135 | echo "📋 composer.json の内容確認:" 136 | cat composer.json 137 | github-comment exec -k common-error -var title:"Check Validation composer.json Failed (PHP: ${{ matrix.php-version }})" -- composer validate --verbose 138 | exit 1 139 | fi 140 | echo "✅ composer.json validation passed" 141 | env: 142 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 143 | 144 | - name: Cache Composer packages 145 | id: composer-cache 146 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 147 | with: 148 | path: vendor 149 | key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.json') }} 150 | restore-keys: | 151 | ${{ runner.os }}-php-${{ matrix.php-version }}- 152 | 153 | - name: Install dependencies 154 | run: | 155 | echo "📦 Installing Composer dependencies..." 156 | composer install --prefer-dist --no-progress --no-interaction 157 | echo "✅ Dependencies installed successfully" 158 | 159 | - name: Setup configuration file 160 | run: | 161 | echo "🔧 設定ファイルセットアップ実行中..." 162 | # 必要なディレクトリを作成 163 | mkdir -p data db config logs 164 | 165 | # config.php.exampleの存在確認 166 | if [ ! -f config/config.php.example ]; then 167 | echo "❌ config/config.php.example が見つかりません" 168 | github-comment post -k common-error -var title:"'config/config.php.example' is not found (PHP: ${{ matrix.php-version }})" -var body:"'config/config.php.example' が見つかりません" 169 | exit 1 170 | fi 171 | 172 | # config.php.exampleからconfig.phpを作成 173 | if [ ! -f config/config.php ]; then 174 | cp config/config.php.example config/config.php 175 | echo "✅ config.php.exampleからconfig.phpを作成しました" 176 | fi 177 | 178 | # 設定ファイルの構文チェック 179 | echo "📄 config.php構文チェック中..." 180 | if ! php -l config/config.php; then 181 | echo "❌ config.phpに構文エラーがあります" 182 | github-comment post -k common-error -var title:"Check 'config.php' Failed (PHP: ${{ matrix.php-version }})" -var body:"'config.php' に構文エラーがあります" 183 | exit 1 184 | fi 185 | echo "✅ 設定ファイルセットアップ完了" 186 | env: 187 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 188 | 189 | - name: Check PHP syntax 190 | run: | 191 | echo "🔍 PHP構文チェックを実行中..." 192 | SYNTAX_ERRORS_COUNT=0 193 | SYNTAX_LOGS="" 194 | while IFS= read -r -d '' file; do 195 | echo "📄 Checking: $file" 196 | SYNTAX_LOGS="$SYNTAX_LOGS\n📄 Checking: $file" 197 | if ! php -l "$file"; then 198 | echo "❌ 構文エラー: $file" 199 | SYNTAX_LOGS="$SYNTAX_LOGS\n❌ 構文エラー: $file" 200 | SYNTAX_ERRORS_COUNT=$((SYNTAX_ERRORS_COUNT + 1)) 201 | fi 202 | done < <(find . -name "*.php" -not -path "./vendor/*" -print0) 203 | 204 | if [ $SYNTAX_ERRORS_COUNT -gt 0 ]; then 205 | echo "❌ $SYNTAX_ERRORS_COUNT 個のPHPファイルに構文エラーがあります" 206 | SYNTAX_LOGS="$SYNTAX_LOGS\n❌ $SYNTAX_ERRORS_COUNT 個のPHPファイルに構文エラーがあります" 207 | github-comment post -k common-error -var title:"Check PHP Syntax Failed (PHP: ${{ matrix.php-version }})" -var body:"$SYNTAX_LOGS" 208 | 209 | exit 1 210 | fi 211 | echo "✅ PHP構文チェック完了 - エラーなし" 212 | env: 213 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 214 | 215 | - name: Run PHP CodeSniffer 216 | run: | 217 | echo "🔍 PHP CodeSniffer実行中..." 218 | if [ -f "vendor/bin/phpcs" ]; then 219 | echo "📋 プロジェクト設定によるコーディング規約チェック開始" 220 | # phpcs.xmlを使用してチェック 221 | if ! vendor/bin/phpcs .; then 222 | echo "❌ コーディング規約違反が検出されました" 223 | echo "🔧 修正方法: vendor/bin/phpcbf" 224 | echo "📋 設定ファイル: phpcs.xml" 225 | github-comment exec -k common-error -var title:"Check PHP CodeSniffer Failed (PHP: ${{ matrix.php-version }})" -- vendor/bin/phpcs . 226 | exit 1 227 | fi 228 | echo "✅ コーディング規約チェック完了" 229 | else 230 | echo "❌ PHPCS not installed..." 231 | github-comment post -k common-error -var title:"Check PHP CodeSniffer Failed (PHP: ${{ matrix.php-version }})" -var body:"PHPCS not installed..." 232 | exit 1 233 | fi 234 | env: 235 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 236 | 237 | - name: Run PHPStan 238 | run: | 239 | echo "🔍 PHPStan静的解析実行中..." 240 | if [ -f "vendor/bin/phpstan" ]; then 241 | echo "📋 静的解析チェック開始 (Level 1)" 242 | if ! vendor/bin/phpstan analyse --level=1 --no-progress .; then 243 | echo "❌ 静的解析でエラーが検出されました" 244 | echo "🔧 詳細は上記のエラーメッセージを確認してください" 245 | github-comment exec -k common-error -var title:"Check PHPStan Failed (PHP: ${{ matrix.php-version }})" -- vendor/bin/phpstan analyse --level=1 --no-progress . 246 | exit 1 247 | fi 248 | echo "✅ 静的解析チェック完了" 249 | else 250 | echo "❌ PHPStan not installed..." 251 | github-comment post -k common-error -var title:"Check PHPStan Failed (PHP: ${{ matrix.php-version }})" -var body:"PHPStan not installed..." 252 | exit 1 253 | fi 254 | env: 255 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 256 | 257 | - name: Test version synchronization 258 | run: | 259 | echo "🧪 バージョン同期テストを実行中..." 260 | php scripts/test-version.php || { 261 | echo "⚠️ バージョンテストで問題が発生しましたが、継続します" 262 | exit 0 263 | } 264 | 265 | - name: Run application tests 266 | run: | 267 | # 基本的な機能テスト(将来のPHPUnit用) 268 | echo "Basic functionality tests would run here" 269 | 270 | - name: Test Docker build 271 | run: | 272 | echo "🐳 Dockerビルドテストを実行中..." 273 | docker build -t phpuploader-test ./docker/ || { 274 | echo "⚠️ Dockerビルドに問題がありましたが、継続します" 275 | exit 0 276 | } 277 | echo "✅ Dockerビルド完了" 278 | -------------------------------------------------------------------------------- /asset/css/responsive.css: -------------------------------------------------------------------------------- 1 | /* =================================================== 2 | PHP Uploader Ver.2.0 - レスポンシブ対応CSS 3 | ================================================= */ 4 | 5 | /* モバイル対応のカード型レイアウト */ 6 | @media (max-width: 768px) { 7 | /* テーブル表示を隠してカード表示に切り替え */ 8 | .file-table-container { 9 | display: none !important; 10 | } 11 | 12 | .file-cards-container { 13 | display: block !important; 14 | } 15 | 16 | /* アップロードフォームの調整 */ 17 | .upload-form .row .col-sm-6 { 18 | margin-bottom: 15px; 19 | } 20 | 21 | .upload-form .col-sm-offset-10 { 22 | margin-left: 0 !important; 23 | width: 100% !important; 24 | } 25 | 26 | .upload-form .btn-submit { 27 | width: 100%; 28 | margin-top: 10px; 29 | } 30 | } 31 | 32 | @media (min-width: 769px) { 33 | /* デスクトップ表示時はカードを隠してテーブル表示 */ 34 | .file-table-container { 35 | display: block !important; 36 | } 37 | 38 | .file-cards-container { 39 | display: none !important; 40 | } 41 | } 42 | 43 | /* =================================================== 44 | カード型レイアウトのスタイル 45 | ================================================= */ 46 | 47 | .file-cards-container { 48 | display: none; /* デフォルトは非表示 */ 49 | margin-top: 20px; 50 | } 51 | 52 | .file-card { 53 | background: #fff; 54 | border: 1px solid #e0e0e0; 55 | border-radius: 8px; 56 | margin-bottom: 15px; 57 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 58 | overflow: hidden; 59 | transition: all 0.3s ease; 60 | } 61 | 62 | .file-card:hover { 63 | box-shadow: 0 4px 12px rgba(0,0,0,0.15); 64 | transform: translateY(-2px); 65 | } 66 | 67 | .file-card__header { 68 | background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); 69 | padding: 16px; 70 | border-bottom: 1px solid #e9ecef; 71 | cursor: pointer; 72 | display: flex; 73 | justify-content: space-between; 74 | align-items: center; 75 | } 76 | 77 | .file-card__header:hover { 78 | background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%); 79 | } 80 | 81 | .file-card__main-info { 82 | flex: 1; 83 | min-width: 0; 84 | } 85 | 86 | .file-card__filename { 87 | font-size: 16px; 88 | font-weight: 600; 89 | color: #007bff; 90 | text-decoration: none; 91 | display: block; 92 | margin-bottom: 4px; 93 | word-wrap: break-word; 94 | transition: color 0.2s ease; 95 | } 96 | 97 | .file-card__filename:hover { 98 | color: #0056b3; 99 | text-decoration: none; 100 | } 101 | 102 | .file-card__comment { 103 | font-size: 14px; 104 | color: #6c757d; 105 | margin: 0; 106 | word-wrap: break-word; 107 | line-height: 1.4; 108 | } 109 | 110 | .file-card__comment:empty:before { 111 | content: "コメントなし"; 112 | color: #adb5bd; 113 | font-style: italic; 114 | } 115 | 116 | .file-card__toggle { 117 | background: none; 118 | border: none; 119 | color: #6c757d; 120 | font-size: 18px; 121 | cursor: pointer; 122 | padding: 8px; 123 | border-radius: 50%; 124 | transition: all 0.3s ease; 125 | width: 36px; 126 | height: 36px; 127 | display: flex; 128 | align-items: center; 129 | justify-content: center; 130 | } 131 | 132 | .file-card__toggle:hover { 133 | background: rgba(0,123,255,0.1); 134 | color: #007bff; 135 | } 136 | 137 | .file-card__toggle.expanded { 138 | transform: rotate(180deg); 139 | color: #007bff; 140 | } 141 | 142 | .file-card__details { 143 | max-height: 0; 144 | overflow: hidden; 145 | transition: max-height 0.4s ease, padding 0.4s ease; 146 | background: #fff; 147 | } 148 | 149 | .file-card__details.expanded { 150 | max-height: 300px; 151 | padding: 20px; 152 | } 153 | 154 | .file-card__detail-grid { 155 | display: grid; 156 | grid-template-columns: 1fr 1fr; 157 | gap: 15px; 158 | margin-bottom: 20px; 159 | } 160 | 161 | .file-card__detail-item { 162 | display: flex; 163 | flex-direction: column; 164 | } 165 | 166 | .file-card__detail-label { 167 | font-size: 12px; 168 | font-weight: 500; 169 | color: #6c757d; 170 | text-transform: uppercase; 171 | letter-spacing: 0.5px; 172 | margin-bottom: 4px; 173 | } 174 | 175 | .file-card__detail-value { 176 | font-size: 14px; 177 | color: #212529; 178 | font-weight: 500; 179 | } 180 | 181 | .file-card__detail-value--size { 182 | font-family: 'Courier New', monospace; 183 | color: #28a745; 184 | } 185 | 186 | .file-card__detail-value--date { 187 | font-size: 13px; 188 | color: #6c757d; 189 | } 190 | 191 | .file-card__detail-value--count { 192 | background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); 193 | color: #1976d2; 194 | padding: 4px 12px; 195 | border-radius: 20px; 196 | font-size: 12px; 197 | font-weight: 600; 198 | display: inline-block; 199 | min-width: 24px; 200 | text-align: center; 201 | } 202 | 203 | .file-card__actions { 204 | display: flex; 205 | gap: 10px; 206 | padding-top: 15px; 207 | border-top: 1px solid #f1f3f4; 208 | } 209 | 210 | .file-card__action-btn { 211 | flex: 1; 212 | background: #fff; 213 | border: 2px solid #007bff; 214 | color: #007bff; 215 | padding: 10px 16px; 216 | border-radius: 6px; 217 | text-decoration: none; 218 | font-size: 14px; 219 | font-weight: 500; 220 | cursor: pointer; 221 | transition: all 0.3s ease; 222 | text-align: center; 223 | display: flex; 224 | align-items: center; 225 | justify-content: center; 226 | } 227 | 228 | .file-card__action-btn:hover { 229 | background: #007bff; 230 | color: #fff; 231 | text-decoration: none; 232 | transform: translateY(-1px); 233 | box-shadow: 0 4px 8px rgba(0,123,255,0.3); 234 | } 235 | 236 | .file-card__action-btn--delete { 237 | border-color: #dc3545; 238 | color: #dc3545; 239 | } 240 | 241 | .file-card__action-btn--delete:hover { 242 | background: #dc3545; 243 | color: #fff; 244 | box-shadow: 0 4px 8px rgba(220,53,69,0.3); 245 | } 246 | 247 | /* =================================================== 248 | アップロードフォームの改善 249 | ================================================= */ 250 | 251 | .upload-form { 252 | margin-bottom: 30px; 253 | } 254 | 255 | .upload-form .form-section { 256 | margin-bottom: 20px; 257 | } 258 | 259 | .upload-form .file-input-group { 260 | position: relative; 261 | margin-bottom: 15px; 262 | } 263 | 264 | .upload-form .btn-submit { 265 | background: linear-gradient(135deg, #28a745 0%, #20c997 100%); 266 | border: none; 267 | color: #fff; 268 | padding: 12px 24px; 269 | border-radius: 6px; 270 | font-weight: 500; 271 | transition: all 0.3s ease; 272 | } 273 | 274 | .upload-form .btn-submit:hover { 275 | transform: translateY(-2px); 276 | box-shadow: 0 4px 12px rgba(40,167,69,0.3); 277 | } 278 | 279 | /* =================================================== 280 | ステータスメッセージの改善 281 | ================================================= */ 282 | 283 | .status-message { 284 | margin-bottom: 20px; 285 | border-radius: 8px; 286 | padding: 16px 20px; 287 | border: none; 288 | font-weight: 500; 289 | animation: slideDown 0.4s ease; 290 | } 291 | 292 | @keyframes slideDown { 293 | from { 294 | opacity: 0; 295 | transform: translateY(-20px); 296 | } 297 | to { 298 | opacity: 1; 299 | transform: translateY(0); 300 | } 301 | } 302 | 303 | .status-message.success { 304 | background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%); 305 | color: #155724; 306 | border-left: 4px solid #28a745; 307 | } 308 | 309 | .status-message.error { 310 | background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%); 311 | color: #721c24; 312 | border-left: 4px solid #dc3545; 313 | } 314 | 315 | /* ================================================= 316 | プログレスバーとアップロード表示 317 | ================================================= */ 318 | 319 | .upload-progress { 320 | margin: 20px 0; 321 | padding: 20px; 322 | background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); 323 | border-radius: 12px; 324 | border: 1px solid #dee2e6; 325 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 326 | } 327 | 328 | .upload-progress h4 { 329 | margin: 0 0 15px 0; 330 | color: #495057; 331 | font-weight: 600; 332 | text-align: center; 333 | } 334 | 335 | .progress { 336 | height: 20px; 337 | background: #e9ecef; 338 | border-radius: 10px; 339 | overflow: hidden; 340 | position: relative; 341 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); 342 | } 343 | 344 | .progress-bar { 345 | background: linear-gradient(45deg, #28a745, #20c997); 346 | height: 100%; 347 | border-radius: 10px; 348 | transition: width 0.3s ease; 349 | position: relative; 350 | overflow: hidden; 351 | } 352 | 353 | .progress-bar::after { 354 | content: ''; 355 | position: absolute; 356 | top: 0; 357 | left: 0; 358 | bottom: 0; 359 | right: 0; 360 | background: linear-gradient( 361 | -45deg, 362 | rgba(255, 255, 255, 0.3) 25%, 363 | transparent 25%, 364 | transparent 50%, 365 | rgba(255, 255, 255, 0.3) 50%, 366 | rgba(255, 255, 255, 0.3) 75%, 367 | transparent 75%, 368 | transparent 369 | ); 370 | background-size: 20px 20px; 371 | animation: progress-stripe 1s linear infinite; 372 | } 373 | 374 | .progress-bar span { 375 | position: absolute; 376 | top: 50%; 377 | left: 50%; 378 | transform: translate(-50%, -50%); 379 | font-weight: 600; 380 | color: #fff; 381 | font-size: 12px; 382 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); 383 | } 384 | 385 | @keyframes progress-stripe { 386 | 0% { background-position: 20px 0; } 387 | 100% { background-position: 0 0; } 388 | } 389 | 390 | /* エラーコンテナ */ 391 | .error-container { 392 | margin: 20px 0; 393 | padding: 20px; 394 | background: linear-gradient(135deg, #fff5f5 0%, #fed7d7 100%); 395 | border: 1px solid #f56565; 396 | border-radius: 12px; 397 | color: #c53030; 398 | box-shadow: 0 2px 10px rgba(245, 101, 101, 0.1); 399 | } 400 | 401 | .error-container .panel-heading h4 { 402 | margin: 0 0 15px 0; 403 | color: #c53030; 404 | font-weight: 600; 405 | text-align: center; 406 | } 407 | 408 | .error-container .panel-body { 409 | color: #c53030; 410 | font-weight: 500; 411 | } 412 | 413 | /* =================================================== 414 | プログレスバーとエラー表示の改善 415 | ================================================= */ 416 | 417 | .upload-progress { 418 | margin-top: 15px; 419 | background: #fff; 420 | border-radius: 8px; 421 | padding: 20px; 422 | box-shadow: 0 2px 8px rgba(0,0,0,0.1); 423 | } 424 | 425 | .upload-progress .progress { 426 | height: 8px; 427 | border-radius: 4px; 428 | background: #e9ecef; 429 | overflow: hidden; 430 | } 431 | 432 | .upload-progress .progress-bar { 433 | background: linear-gradient(90deg, #007bff 0%, #28a745 100%); 434 | transition: width 0.5s ease; 435 | border-radius: 4px; 436 | } 437 | 438 | .error-container { 439 | margin-top: 15px; 440 | background: #fff; 441 | border-radius: 8px; 442 | padding: 20px; 443 | border-left: 4px solid #dc3545; 444 | box-shadow: 0 2px 8px rgba(220,53,69,0.1); 445 | } 446 | 447 | /* =================================================== 448 | アニメーション効果 449 | ================================================= */ 450 | 451 | .fade-in { 452 | animation: fadeIn 0.5s ease; 453 | } 454 | 455 | @keyframes fadeIn { 456 | from { opacity: 0; } 457 | to { opacity: 1; } 458 | } 459 | 460 | .slide-up { 461 | animation: slideUp 0.3s ease; 462 | } 463 | 464 | @keyframes slideUp { 465 | from { 466 | opacity: 0; 467 | transform: translateY(20px); 468 | } 469 | to { 470 | opacity: 1; 471 | transform: translateY(0); 472 | } 473 | } 474 | -------------------------------------------------------------------------------- /asset/css/file-manager.css: -------------------------------------------------------------------------------- 1 | /* 2 | * ファイル一覧カード + 検索・ページネーション機能 3 | * DataTables完全廃止版 Ver.2.0 4 | */ 5 | 6 | /* === ファイル一覧コンテナ === */ 7 | .file-manager { 8 | margin-top: 20px; 9 | } 10 | 11 | .file-manager__header { 12 | display: flex; 13 | justify-content: space-between; 14 | align-items: center; 15 | margin-bottom: 20px; 16 | flex-wrap: wrap; 17 | gap: 15px; 18 | } 19 | 20 | .file-manager__title { 21 | margin: 0; 22 | font-size: 24px; 23 | color: #333; 24 | display: flex; 25 | align-items: center; 26 | gap: 8px; 27 | } 28 | 29 | .file-manager__stats { 30 | font-size: 14px; 31 | color: #6c757d; 32 | } 33 | 34 | /* === コントロール全体 === */ 35 | .file-controls { 36 | display: flex; 37 | justify-content: space-between; 38 | align-items: center; 39 | margin-bottom: 20px; 40 | flex-wrap: wrap; 41 | gap: 15px; 42 | } 43 | 44 | /* === 検索・フィルター === */ 45 | .file-search { 46 | display: flex; 47 | gap: 10px; 48 | align-items: center; 49 | flex-wrap: wrap; 50 | flex: 1; 51 | } 52 | 53 | .file-search__input { 54 | flex: 1; 55 | min-width: 200px; 56 | max-width: 400px; 57 | } 58 | 59 | .file-search__input input { 60 | width: 100%; 61 | padding: 8px 12px; 62 | border: 1px solid #ddd; 63 | border-radius: 4px; 64 | font-size: 14px; 65 | } 66 | 67 | .file-search__input input:focus { 68 | outline: none; 69 | border-color: #007bff; 70 | box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); 71 | } 72 | 73 | .file-search__sort { 74 | display: flex; 75 | align-items: center; 76 | gap: 5px; 77 | } 78 | 79 | .file-search__sort select { 80 | padding: 6px 10px; 81 | border: 1px solid #ddd; 82 | border-radius: 4px; 83 | font-size: 14px; 84 | background: white; 85 | } 86 | 87 | .file-search__clear { 88 | padding: 6px 12px; 89 | background: #6c757d; 90 | color: white; 91 | border: none; 92 | border-radius: 4px; 93 | cursor: pointer; 94 | font-size: 14px; 95 | } 96 | 97 | .file-search__clear:hover { 98 | background: #5a6268; 99 | } 100 | 101 | /* === ビュー切り替えボタン === */ 102 | .file-view-toggle { 103 | display: flex; 104 | gap: 2px; 105 | border: 1px solid #ddd; 106 | border-radius: 4px; 107 | overflow: hidden; 108 | } 109 | 110 | .file-view-toggle__btn { 111 | padding: 6px 12px; 112 | background: white; 113 | color: #6c757d; 114 | border: none; 115 | cursor: pointer; 116 | font-size: 14px; 117 | transition: all 0.2s ease; 118 | } 119 | 120 | .file-view-toggle__btn:hover { 121 | background: #f8f9fa; 122 | color: #333; 123 | } 124 | 125 | .file-view-toggle__btn--active { 126 | background: #007bff; 127 | color: white; 128 | } 129 | 130 | .file-view-toggle__btn--active:hover { 131 | background: #0056b3; 132 | } 133 | 134 | /* === 統一カードレイアウト === */ 135 | .file-cards { 136 | display: grid; 137 | grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); 138 | gap: 16px; 139 | margin-bottom: 20px; 140 | } 141 | 142 | /* === リストビューレイアウト === */ 143 | .file-list { 144 | display: flex; 145 | flex-direction: column; 146 | gap: 8px; 147 | margin-bottom: 20px; 148 | } 149 | 150 | .file-list-item { 151 | display: flex; 152 | align-items: center; 153 | background: white; 154 | border: 1px solid #e9ecef; 155 | border-radius: 6px; 156 | padding: 12px 16px; 157 | transition: all 0.2s ease; 158 | gap: 12px; 159 | } 160 | 161 | .file-list-item:hover { 162 | box-shadow: 0 2px 6px rgba(0,0,0,0.1); 163 | border-color: #007bff; 164 | } 165 | 166 | .file-list-item__icon { 167 | font-size: 24px; 168 | width: 32px; 169 | text-align: center; 170 | flex-shrink: 0; 171 | } 172 | 173 | .file-list-item__main { 174 | flex: 1; 175 | min-width: 0; 176 | } 177 | 178 | .file-list-item__info { 179 | margin-bottom: 4px; 180 | } 181 | 182 | .file-list-item__filename { 183 | display: block; 184 | font-size: 16px; 185 | font-weight: 600; 186 | color: #007bff; 187 | text-decoration: none; 188 | margin-bottom: 2px; 189 | word-break: break-word; 190 | } 191 | 192 | .file-list-item__filename:hover { 193 | color: #0056b3; 194 | text-decoration: underline; 195 | } 196 | 197 | .file-list-item__comment { 198 | font-size: 13px; 199 | color: #6c757d; 200 | margin: 0; 201 | line-height: 1.3; 202 | max-height: 2.6em; 203 | overflow: hidden; 204 | text-overflow: ellipsis; 205 | display: -webkit-box; 206 | -webkit-line-clamp: 2; 207 | -webkit-box-orient: vertical; 208 | } 209 | 210 | .file-list-item__meta { 211 | display: flex; 212 | gap: 12px; 213 | flex-wrap: wrap; 214 | } 215 | 216 | .file-list-item__meta-item { 217 | font-size: 12px; 218 | color: #6c757d; 219 | display: flex; 220 | align-items: center; 221 | gap: 2px; 222 | } 223 | 224 | .file-list-item__meta-label { 225 | font-weight: 500; 226 | } 227 | 228 | .file-list-item__meta-value { 229 | color: #333; 230 | font-weight: 600; 231 | } 232 | 233 | .file-list-item__actions { 234 | display: flex; 235 | gap: 6px; 236 | flex-shrink: 0; 237 | } 238 | 239 | .file-list-item__btn { 240 | width: 32px; 241 | height: 32px; 242 | border: 1px solid #ddd; 243 | background: white; 244 | color: #333; 245 | text-decoration: none; 246 | display: flex; 247 | align-items: center; 248 | justify-content: center; 249 | border-radius: 4px; 250 | font-size: 16px; 251 | transition: all 0.2s ease; 252 | cursor: pointer; 253 | } 254 | 255 | .file-list-item__btn:hover { 256 | background: #f8f9fa; 257 | border-color: #007bff; 258 | color: #007bff; 259 | text-decoration: none; 260 | } 261 | 262 | .file-list-item__btn--delete { 263 | border-color: #dc3545; 264 | color: #dc3545; 265 | } 266 | 267 | .file-list-item__btn--delete:hover { 268 | background: #dc3545; 269 | color: white; 270 | } 271 | 272 | .file-card-v2 { 273 | background: white; 274 | border: 1px solid #e9ecef; 275 | border-radius: 8px; 276 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 277 | transition: all 0.2s ease; 278 | overflow: hidden; 279 | } 280 | 281 | .file-card-v2:hover { 282 | box-shadow: 0 4px 8px rgba(0,0,0,0.15); 283 | transform: translateY(-2px); 284 | } 285 | 286 | .file-card-v2__header { 287 | padding: 16px; 288 | background: #f8f9fa; 289 | border-bottom: 1px solid #e9ecef; 290 | } 291 | 292 | .file-card-v2__filename { 293 | display: block; 294 | font-size: 16px; 295 | font-weight: 600; 296 | color: #007bff; 297 | text-decoration: none; 298 | margin-bottom: 4px; 299 | word-break: break-word; 300 | } 301 | 302 | .file-card-v2__filename:hover { 303 | color: #0056b3; 304 | text-decoration: underline; 305 | } 306 | 307 | .file-card-v2__comment { 308 | font-size: 14px; 309 | color: #6c757d; 310 | margin: 0; 311 | line-height: 1.4; 312 | max-height: 2.8em; 313 | overflow: hidden; 314 | text-overflow: ellipsis; 315 | display: -webkit-box; 316 | -webkit-line-clamp: 2; 317 | -webkit-box-orient: vertical; 318 | } 319 | 320 | .file-card-v2__body { 321 | padding: 16px; 322 | } 323 | 324 | .file-card-v2__meta { 325 | display: grid; 326 | grid-template-columns: 1fr 1fr; 327 | gap: 12px 16px; 328 | margin-bottom: 16px; 329 | } 330 | 331 | .file-card-v2__meta-item { 332 | display: flex; 333 | align-items: center; 334 | gap: 6px; 335 | font-size: 14px; 336 | } 337 | 338 | .file-card-v2__meta-icon { 339 | font-size: 16px; 340 | width: 20px; 341 | text-align: center; 342 | } 343 | 344 | .file-card-v2__meta-label { 345 | color: #6c757d; 346 | font-weight: 500; 347 | min-width: 40px; 348 | } 349 | 350 | .file-card-v2__meta-value { 351 | color: #333; 352 | font-weight: 600; 353 | } 354 | 355 | .file-card-v2__actions { 356 | display: flex; 357 | gap: 8px; 358 | border-top: 1px solid #e9ecef; 359 | padding-top: 12px; 360 | } 361 | 362 | .file-card-v2__btn { 363 | flex: 1; 364 | padding: 8px 12px; 365 | border: 1px solid #007bff; 366 | background: white; 367 | color: #007bff; 368 | text-decoration: none; 369 | text-align: center; 370 | border-radius: 4px; 371 | font-size: 14px; 372 | font-weight: 500; 373 | transition: all 0.2s ease; 374 | cursor: pointer; 375 | } 376 | 377 | .file-card-v2__btn:hover { 378 | background: #007bff; 379 | color: white; 380 | text-decoration: none; 381 | } 382 | 383 | .file-card-v2__btn--delete { 384 | border-color: #dc3545; 385 | color: #dc3545; 386 | } 387 | 388 | .file-card-v2__btn--delete:hover { 389 | background: #dc3545; 390 | color: white; 391 | } 392 | 393 | /* === ページネーション === */ 394 | .file-pagination { 395 | display: flex; 396 | justify-content: space-between; 397 | align-items: center; 398 | margin-top: 20px; 399 | flex-wrap: wrap; 400 | gap: 15px; 401 | } 402 | 403 | .file-pagination__info { 404 | font-size: 14px; 405 | color: #6c757d; 406 | } 407 | 408 | .file-pagination__controls { 409 | display: flex; 410 | align-items: center; 411 | gap: 10px; 412 | } 413 | 414 | .file-pagination__per-page { 415 | display: flex; 416 | align-items: center; 417 | gap: 5px; 418 | font-size: 14px; 419 | } 420 | 421 | .file-pagination__per-page select { 422 | padding: 4px 8px; 423 | border: 1px solid #ddd; 424 | border-radius: 4px; 425 | font-size: 14px; 426 | } 427 | 428 | .file-pagination__nav { 429 | display: flex; 430 | gap: 5px; 431 | } 432 | 433 | .file-pagination__btn { 434 | padding: 6px 12px; 435 | border: 1px solid #ddd; 436 | background: white; 437 | color: #333; 438 | text-decoration: none; 439 | border-radius: 4px; 440 | font-size: 14px; 441 | cursor: pointer; 442 | transition: all 0.2s ease; 443 | } 444 | 445 | .file-pagination__btn:hover:not(:disabled) { 446 | background: #f8f9fa; 447 | border-color: #007bff; 448 | color: #007bff; 449 | text-decoration: none; 450 | } 451 | 452 | .file-pagination__btn:disabled { 453 | opacity: 0.5; 454 | cursor: not-allowed; 455 | } 456 | 457 | .file-pagination__btn--active { 458 | background: #007bff; 459 | border-color: #007bff; 460 | color: white; 461 | } 462 | 463 | .file-pagination__btn--active:hover { 464 | background: #0056b3; 465 | border-color: #0056b3; 466 | } 467 | 468 | /* === 空状態 === */ 469 | .file-empty { 470 | text-align: center; 471 | padding: 60px 20px; 472 | color: #6c757d; 473 | } 474 | 475 | .file-empty__icon { 476 | font-size: 48px; 477 | margin-bottom: 16px; 478 | opacity: 0.5; 479 | } 480 | 481 | .file-empty__title { 482 | font-size: 20px; 483 | margin-bottom: 8px; 484 | color: #495057; 485 | } 486 | 487 | .file-empty__message { 488 | font-size: 14px; 489 | margin: 0; 490 | } 491 | 492 | /* === 検索結果なし === */ 493 | .file-no-results { 494 | text-align: center; 495 | padding: 40px 20px; 496 | color: #6c757d; 497 | } 498 | 499 | /* === ローディング === */ 500 | .file-loading { 501 | text-align: center; 502 | padding: 40px 20px; 503 | color: #6c757d; 504 | } 505 | 506 | .file-loading__spinner { 507 | display: inline-block; 508 | width: 20px; 509 | height: 20px; 510 | border: 2px solid #f3f3f3; 511 | border-top: 2px solid #007bff; 512 | border-radius: 50%; 513 | animation: spin 1s linear infinite; 514 | margin-right: 8px; 515 | } 516 | 517 | @keyframes spin { 518 | 0% { transform: rotate(0deg); } 519 | 100% { transform: rotate(360deg); } 520 | } 521 | 522 | /* === レスポンシブ === */ 523 | @media (max-width: 768px) { 524 | .file-cards { 525 | grid-template-columns: 1fr; 526 | gap: 12px; 527 | } 528 | 529 | .file-card-v2__meta { 530 | grid-template-columns: 1fr; 531 | gap: 8px; 532 | } 533 | 534 | .file-manager__header { 535 | flex-direction: column; 536 | align-items: flex-start; 537 | } 538 | 539 | .file-controls { 540 | flex-direction: column; 541 | align-items: stretch; 542 | } 543 | 544 | .file-search { 545 | flex-direction: column; 546 | align-items: stretch; 547 | } 548 | 549 | .file-search__input { 550 | max-width: none; 551 | } 552 | 553 | .file-view-toggle { 554 | align-self: center; 555 | } 556 | 557 | .file-pagination { 558 | flex-direction: column; 559 | align-items: center; 560 | text-align: center; 561 | } 562 | 563 | .file-pagination__controls { 564 | flex-direction: column; 565 | width: 100%; 566 | } 567 | 568 | /* リストビューのレスポンシブ対応 */ 569 | .file-list-item { 570 | flex-direction: column; 571 | align-items: flex-start; 572 | gap: 8px; 573 | } 574 | 575 | .file-list-item__main { 576 | width: 100%; 577 | } 578 | 579 | .file-list-item__meta { 580 | justify-content: space-between; 581 | } 582 | 583 | .file-list-item__actions { 584 | align-self: center; 585 | margin-top: 4px; 586 | } 587 | } 588 | 589 | @media (max-width: 480px) { 590 | .file-card-v2__actions { 591 | flex-direction: column; 592 | } 593 | 594 | .file-pagination__nav { 595 | flex-wrap: wrap; 596 | justify-content: center; 597 | } 598 | 599 | .file-list-item__meta { 600 | flex-direction: column; 601 | align-items: flex-start; 602 | gap: 4px; 603 | } 604 | } 605 | 606 | /* === 隠しクラス(DataTables削除用) === */ 607 | .file-table-container { 608 | display: none !important; 609 | } 610 | 611 | /* DataTablesスタイルの上書き */ 612 | .dataTables_wrapper { 613 | display: none !important; 614 | } 615 | -------------------------------------------------------------------------------- /asset/js/common.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function(){ 2 | 3 | // === 新しいファイル管理システム初期化 (Ver.2.0) === 4 | if (window.fileData && document.getElementById('fileManagerContainer')) { 5 | // DataTables完全廃止・新ファイルマネージャー使用 6 | const fileManager = new FileManager( 7 | document.getElementById('fileManagerContainer'), { 8 | itemsPerPage: 12, 9 | defaultSort: 'date_desc' 10 | } 11 | ); 12 | 13 | // PHPから渡されたファイルデータを設定 14 | fileManager.setFiles(window.fileData); 15 | 16 | // グローバルに公開(デバッグ・外部操作用) 17 | window.fileManagerInstance = fileManager; 18 | } 19 | 20 | // === レガシー DataTables 処理(Ver.2.0では無効化) === 21 | if(document.getElementById('fileList') != null && !window.fileData){ 22 | // Ver.1.x互換性のための緊急フォールバック 23 | console.warn('FileManager initialization failed, falling back to DataTables'); 24 | 25 | $.extend( $.fn.dataTable.defaults, { 26 | language: { 27 | url: 'https://cdn.datatables.net/plug-ins/9dcbecd42ad/i18n/Japanese.json' 28 | } 29 | }); 30 | 31 | $('#fileList').DataTable({ 32 | "order": [ [0, "desc"] ], 33 | "columnDefs": [ { 34 | "ordered": false, 35 | "targets": [6] 36 | } ] 37 | }); 38 | } 39 | 40 | $('input[id=lefile]').change(function() { 41 | $('#fileInput').val($(this).val().replace('C:\\fakepath\\', '')); 42 | }); 43 | 44 | // ステータスメッセージの自動非表示 45 | if ($('#statusMessage').length > 0) { 46 | setTimeout(function() { 47 | $('#statusMessage').fadeOut(); 48 | }, 5000); 49 | } 50 | }); 51 | 52 | // エラー表示ヘルパー関数 53 | function showError(message) { 54 | $('#errorContainer > .panel-body').html(message); 55 | $('#errorContainer').fadeIn(); 56 | } 57 | 58 | // 成功表示ヘルパー関数 59 | function showSuccess(message) { 60 | // 成功用のコンテナがない場合は作成 61 | if ($('#successContainer').length === 0) { 62 | var successHtml = ''; 66 | $('#errorContainer').after(successHtml); 67 | } 68 | $('#successContainer > .panel-body').html(message); 69 | $('#successContainer').fadeIn(); 70 | 71 | // 3秒後に自動で非表示 72 | setTimeout(function() { 73 | $('#successContainer').fadeOut(); 74 | }, 3000); 75 | } 76 | 77 | // CSRFトークンを取得する関数 78 | function getCSRFToken() { 79 | return $('#csrfToken').val(); 80 | } 81 | 82 | // カードの詳細部分の開閉機能 83 | function toggleCardDetails(element) { 84 | var $header = $(element); 85 | var $details = $header.next('.file-card__details'); 86 | var $toggle = $header.find('.file-card__toggle'); 87 | 88 | if ($details.hasClass('expanded')) { 89 | // 閉じる 90 | $details.removeClass('expanded'); 91 | $toggle.removeClass('expanded'); 92 | } else { 93 | // 開く 94 | $details.addClass('expanded'); 95 | $toggle.addClass('expanded'); 96 | } 97 | } 98 | 99 | // プログレスバーのテキスト更新 100 | function updateProgressBar(percent) { 101 | $('#progressBar').css({width: percent + '%'}); 102 | $('#progressText').text(percent + '%'); 103 | } 104 | 105 | // 画面リサイズ時の対応 106 | $(window).resize(function() { 107 | // 新ファイルマネージャーのリサイズ対応 108 | if (window.fileManagerInstance) { 109 | window.fileManagerInstance.refresh(); 110 | } 111 | 112 | // レガシー DataTables 対応(フォールバック用) 113 | if ($(window).width() > 768 && $.fn.DataTable && $.fn.DataTable.isDataTable('#fileList')) { 114 | $('#fileList').DataTable().columns.adjust(); 115 | } 116 | }); 117 | 118 | function file_upload() 119 | { 120 | if($('#fileInput').val() == ''){ 121 | showError('ファイルを選択してください。'); 122 | return; 123 | } 124 | 125 | $('#errorContainer').fadeOut(); 126 | $('#uploadContainer').fadeIn(); 127 | // フォームデータを取得 128 | var formdata = new FormData($('#upload').get(0)); 129 | 130 | // POSTでアップロード 131 | $.ajax({ 132 | url : './app/api/upload.php', 133 | type : 'POST', 134 | data : formdata, 135 | cache : false, 136 | contentType : false, 137 | processData : false, 138 | dataType : 'json', 139 | async: true, 140 | xhr : function(){ 141 | var XHR = $.ajaxSettings.xhr(); 142 | if(XHR.upload){ 143 | XHR.upload.addEventListener('progress',function(e){ 144 | var progre = parseInt(e.loaded/e.total*100); 145 | updateProgressBar(progre); 146 | }, false); 147 | } 148 | return XHR; 149 | }, 150 | }) 151 | .done(function(data, textStatus, jqXHR){ 152 | if (data.status === 'success') { 153 | // 成功時はページをリロード(新ファイルマネージャーはリロード後に更新される) 154 | showSuccess(data.message || 'ファイルのアップロードが完了しました。'); 155 | setTimeout(function() { 156 | location.reload(); 157 | }, 1500); 158 | } else if (data.status === 'error') { 159 | // Ver.2.0のエラーレスポンス形式に対応 160 | var errorMessage = ''; 161 | 162 | // バリデーションエラーがある場合は詳細を表示 163 | if (data.validation_errors && data.validation_errors.length > 0) { 164 | errorMessage = 'バリデーションエラー:
' + data.validation_errors.join('
'); 165 | } else if (data.message) { 166 | errorMessage = data.message; 167 | } else { 168 | errorMessage = 'アップロードに失敗しました。'; 169 | } 170 | 171 | // エラーコードがある場合は追加情報として表示 172 | if (data.error_code) { 173 | errorMessage += '
(エラーコード: ' + data.error_code + ')'; 174 | } 175 | 176 | showError(errorMessage); 177 | } else { 178 | // 旧バージョン互換 179 | switch (data.status){ 180 | case 'filesize_over': 181 | showError('ファイル容量が大きすぎます。'); 182 | break; 183 | case 'extension_error': 184 | showError('許可されていない拡張子です。拡張子:'+data.ext); 185 | break; 186 | case 'comment_error': 187 | showError('コメントの文字数が規定数を超えています。'); 188 | break; 189 | case 'sqlwrite_error': 190 | showError('データベースの書き込みに失敗しました。'); 191 | break; 192 | case 'ok': 193 | location.reload(); 194 | break; 195 | default: 196 | showError('アップロードに失敗しました: ' + (data.message || '不明なエラー')); 197 | } 198 | } 199 | }) 200 | .fail(function(jqXHR, textStatus, errorThrown){ 201 | var errorMsg = 'サーバーエラーが発生しました。'; 202 | 203 | // レスポンスがJSONの場合は詳細情報を取得 204 | if (jqXHR.responseJSON) { 205 | if (jqXHR.responseJSON.message) { 206 | errorMsg = jqXHR.responseJSON.message; 207 | } 208 | if (jqXHR.responseJSON.error_code) { 209 | errorMsg += '
(エラーコード: ' + jqXHR.responseJSON.error_code + ')'; 210 | } 211 | if (jqXHR.responseJSON.validation_errors && jqXHR.responseJSON.validation_errors.length > 0) { 212 | errorMsg += '
詳細:
' + jqXHR.responseJSON.validation_errors.join('
'); 213 | } 214 | } else if (jqXHR.responseText) { 215 | // JSONでない場合はテキスト内容を確認 216 | try { 217 | var parsed = JSON.parse(jqXHR.responseText); 218 | if (parsed.message) { 219 | errorMsg = parsed.message; 220 | } 221 | } catch(e) { 222 | // JSONパースに失敗した場合はHTTPステータスを表示 223 | errorMsg += '
(HTTP ' + jqXHR.status + ': ' + errorThrown + ')'; 224 | } 225 | } 226 | 227 | showError(errorMsg); 228 | }) 229 | .always(function( jqXHR, textStatus ) { 230 | $('#uploadContainer').hide(); 231 | }); 232 | } 233 | 234 | // DLボタンを押すと実行 235 | function dl_button(id){ 236 | // DLkey空白で投げる 237 | dl_certificat(id ,''); 238 | } 239 | 240 | function confirm_dl_button(id){ 241 | closeModal(); 242 | dl_certificat(id ,$('#confirmDlkeyInput').val()); 243 | } 244 | 245 | function dl_certificat(id, key){ 246 | var postdata = { 247 | id: id, 248 | key: key, 249 | csrf_token: getCSRFToken() 250 | }; 251 | 252 | $.ajax({ 253 | url : './app/api/verifydownload.php', 254 | type : 'POST', 255 | data : postdata, 256 | dataType : 'json' 257 | }) 258 | .done(function(data, textStatus, jqXHR){ 259 | if (data.status === 'success') { 260 | // Ver.2.0の成功レスポンス 261 | location.href = './download.php?id=' + data.data.id + '&key=' + data.data.token; 262 | } else if (data.status === 'error') { 263 | // Ver.2.0のエラーレスポンス 264 | if (data.error_code === 'AUTH_REQUIRED' || data.error_code === 'INVALID_KEY') { 265 | // 認証が必要 266 | var html = '
' + 267 | '' + 268 | '' + 269 | '
'; 270 | openModal('okcansel', '認証が必要です', html, 'confirm_dl_button(' + id + ');'); 271 | } else { 272 | var errorMessage = data.message || 'ダウンロードに失敗しました。'; 273 | if (data.error_code) { 274 | errorMessage += '
(エラーコード: ' + data.error_code + ')'; 275 | } 276 | showError(errorMessage); 277 | } 278 | } else { 279 | // 旧バージョン互換 280 | var html = '
' + 281 | '' + 282 | '' + 283 | '
'; 284 | switch (data.status){ 285 | case 'failed': 286 | openModal('okcansel', '認証が必要です', html, 'confirm_dl_button(' + id + ');'); 287 | break; 288 | case 'ok': 289 | location.href = './download.php?id=' + data.id + '&key=' + data.key; 290 | break; 291 | default: 292 | showError('ダウンロードに失敗しました。'); 293 | } 294 | } 295 | }) 296 | .fail(function(jqXHR, textStatus, errorThrown){ 297 | var errorMsg = 'ダウンロード処理でサーバーエラーが発生しました。'; 298 | 299 | // レスポンスがJSONの場合は詳細情報を取得 300 | if (jqXHR.responseJSON) { 301 | if (jqXHR.responseJSON.message) { 302 | errorMsg = jqXHR.responseJSON.message; 303 | } 304 | if (jqXHR.responseJSON.error_code) { 305 | errorMsg += '
(エラーコード: ' + jqXHR.responseJSON.error_code + ')'; 306 | } 307 | } else if (jqXHR.responseText) { 308 | try { 309 | var parsed = JSON.parse(jqXHR.responseText); 310 | if (parsed.message) { 311 | errorMsg = parsed.message; 312 | } 313 | } catch(e) { 314 | errorMsg += '
(HTTP ' + jqXHR.status + ': ' + errorThrown + ')'; 315 | } 316 | } 317 | 318 | showError(errorMsg); 319 | }) 320 | .always(function( jqXHR, textStatus ) { 321 | }); 322 | } 323 | 324 | // DELボタンを押すと実行 325 | function del_button(id){ 326 | // DLkey空白で投げる 327 | del_certificat(id ,''); 328 | } 329 | 330 | function confirm_del_button(id){ 331 | closeModal(); 332 | del_certificat(id ,$('#confirmDelkeyInput').val()); 333 | } 334 | 335 | function del_certificat(id, key){ 336 | var postdata = { 337 | id: id, 338 | key: key, 339 | csrf_token: getCSRFToken() 340 | }; 341 | 342 | $.ajax({ 343 | url : './app/api/verifydelete.php', 344 | type : 'POST', 345 | data : postdata, 346 | dataType : 'json' 347 | }) 348 | .done(function(data, textStatus, jqXHR){ 349 | if (data.status === 'success') { 350 | // Ver.2.0の成功レスポンス 351 | location.href = './delete.php?id=' + data.data.id + '&key=' + data.data.token; 352 | } else if (data.status === 'error') { 353 | // Ver.2.0のエラーレスポンス 354 | if (data.error_code === 'AUTH_REQUIRED' || data.error_code === 'INVALID_KEY') { 355 | // 認証が必要 356 | var html = '
' + 357 | '' + 358 | '' + 359 | '
'; 360 | openModal('okcansel', '認証が必要です', html, 'confirm_del_button(' + id + ');'); 361 | } else { 362 | var errorMessage = data.message || '削除に失敗しました。'; 363 | if (data.error_code) { 364 | errorMessage += '
(エラーコード: ' + data.error_code + ')'; 365 | } 366 | showError(errorMessage); 367 | } 368 | } else { 369 | // 旧バージョン互換 370 | var html = '
' + 371 | '' + 372 | '' + 373 | '
'; 374 | switch (data.status){ 375 | case 'failed': 376 | openModal('okcansel', '認証が必要です', html, 'confirm_del_button(' + id + ');'); 377 | break; 378 | case 'ok': 379 | location.href = './delete.php?id=' + data.id + '&key=' + data.key; 380 | break; 381 | default: 382 | showError('削除に失敗しました。'); 383 | } 384 | } 385 | }) 386 | .fail(function(jqXHR, textStatus, errorThrown){ 387 | var errorMsg = '削除処理でサーバーエラーが発生しました。'; 388 | 389 | // レスポンスがJSONの場合は詳細情報を取得 390 | if (jqXHR.responseJSON) { 391 | if (jqXHR.responseJSON.message) { 392 | errorMsg = jqXHR.responseJSON.message; 393 | } 394 | if (jqXHR.responseJSON.error_code) { 395 | errorMsg += '
(エラーコード: ' + jqXHR.responseJSON.error_code + ')'; 396 | } 397 | } else if (jqXHR.responseText) { 398 | try { 399 | var parsed = JSON.parse(jqXHR.responseText); 400 | if (parsed.message) { 401 | errorMsg = parsed.message; 402 | } 403 | } catch(e) { 404 | errorMsg += '
(HTTP ' + jqXHR.status + ': ' + errorThrown + ')'; 405 | } 406 | } 407 | 408 | showError(errorMsg); 409 | }) 410 | .always(function( jqXHR, textStatus ) { 411 | }); 412 | } 413 | -------------------------------------------------------------------------------- /asset/js/file-manager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ファイル管理システム Ver.2.0 3 | * DataTables完全廃止版 - 検索・ソート・ページネーション機能付き 4 | */ 5 | 6 | class FileManager { 7 | constructor(container, options = {}) { 8 | this.container = container; 9 | this.files = []; 10 | this.filteredFiles = []; 11 | this.currentPage = 1; 12 | this.itemsPerPage = options.itemsPerPage || 12; 13 | this.searchQuery = ''; 14 | this.sortBy = options.defaultSort || 'date_desc'; 15 | 16 | // ビューモードの初期化(localStorage から復元) 17 | this.viewMode = this.loadViewMode() || options.defaultView || 'grid'; 18 | 19 | this.init(); 20 | } 21 | 22 | // ユーザーのビューモード設定を読み込み 23 | loadViewMode() { 24 | try { 25 | return localStorage.getItem('fileManager_viewMode'); 26 | } catch (e) { 27 | return null; 28 | } 29 | } 30 | 31 | init() { 32 | this.render(); 33 | this.bindEvents(); 34 | } 35 | 36 | setFiles(files) { 37 | this.files = files; 38 | this.applyFilters(); 39 | this.render(); 40 | } 41 | 42 | applyFilters() { 43 | let filtered = [...this.files]; 44 | 45 | // 検索フィルター 46 | if (this.searchQuery) { 47 | const query = this.searchQuery.toLowerCase(); 48 | filtered = filtered.filter(file => 49 | file.origin_file_name.toLowerCase().includes(query) || 50 | file.comment.toLowerCase().includes(query) || 51 | this.getFileExtension(file.origin_file_name).toLowerCase().includes(query) 52 | ); 53 | } 54 | 55 | // ソート適用 56 | filtered.sort((a, b) => { 57 | switch (this.sortBy) { 58 | case 'name_asc': 59 | return a.origin_file_name.localeCompare(b.origin_file_name); 60 | case 'name_desc': 61 | return b.origin_file_name.localeCompare(a.origin_file_name); 62 | case 'size_asc': 63 | return a.size - b.size; 64 | case 'size_desc': 65 | return b.size - a.size; 66 | case 'downloads_asc': 67 | return a.count - b.count; 68 | case 'downloads_desc': 69 | return b.count - a.count; 70 | case 'date_asc': 71 | return a.input_date - b.input_date; 72 | case 'date_desc': 73 | default: 74 | return b.input_date - a.input_date; 75 | } 76 | }); 77 | 78 | this.filteredFiles = filtered; 79 | this.currentPage = 1; // 検索・ソート時は1ページ目に戻る 80 | } 81 | 82 | render() { 83 | // フォーカス状態を保存 84 | const activeElement = document.activeElement; 85 | const wasSearchFocused = activeElement && activeElement.id === 'fileSearchInput'; 86 | const searchValue = wasSearchFocused ? activeElement.value : this.searchQuery; 87 | const cursorPosition = wasSearchFocused ? activeElement.selectionStart : 0; 88 | 89 | const startIndex = (this.currentPage - 1) * this.itemsPerPage; 90 | const endIndex = startIndex + this.itemsPerPage; 91 | const pageFiles = this.filteredFiles.slice(startIndex, endIndex); 92 | 93 | this.container.innerHTML = ` 94 |
95 | ${this.renderHeader()} 96 | ${this.renderControls()} 97 | ${this.renderContent(pageFiles)} 98 | ${this.renderPagination()} 99 |
100 | `; 101 | 102 | // フォーカス状態を復元 103 | if (wasSearchFocused) { 104 | const searchInput = document.getElementById('fileSearchInput'); 105 | if (searchInput) { 106 | searchInput.focus(); 107 | searchInput.setSelectionRange(cursorPosition, cursorPosition); 108 | } 109 | } 110 | } 111 | 112 | renderHeader() { 113 | const totalFiles = this.files.length; 114 | const filteredCount = this.filteredFiles.length; 115 | 116 | return ` 117 |
118 |

119 | 📁 ファイル一覧 120 |

121 |
122 | ${filteredCount !== totalFiles ? 123 | `${filteredCount}件 (全${totalFiles}件中)` : 124 | `${totalFiles}件` 125 | } 126 |
127 |
128 | `; 129 | } 130 | 131 | renderControls() { 132 | return ` 133 |
134 | 162 | 163 |
164 | 171 | 178 |
179 |
180 | `; 181 | } 182 | 183 | renderSearch() { 184 | // 旧バージョン互換用(使用されない) 185 | return this.renderControls(); 186 | } 187 | 188 | renderContent(files) { 189 | if (files.length === 0) { 190 | if (this.filteredFiles.length === 0 && this.files.length === 0) { 191 | return ` 192 |
193 |
📄
194 |

アップロードされたファイルはありません

195 |

上のフォームからファイルをアップロードしてください。

196 |
197 | `; 198 | } else { 199 | return ` 200 |
201 |
🔍
202 |

検索結果が見つかりません

203 |

検索条件を変更してお試しください。

204 |
205 | `; 206 | } 207 | } 208 | 209 | if (this.viewMode === 'list') { 210 | return ` 211 |
212 | ${files.map(file => this.renderFileListItem(file)).join('')} 213 |
214 | `; 215 | } else { 216 | return ` 217 |
218 | ${files.map(file => this.renderFileCard(file)).join('')} 219 |
220 | `; 221 | } 222 | } 223 | 224 | renderFileListItem(file) { 225 | const fileSize = (file.size / (1024 * 1024)).toFixed(1); 226 | const uploadDate = new Date(file.input_date * 1000); 227 | const formattedDate = uploadDate.toLocaleDateString('ja-JP', { 228 | year: 'numeric', 229 | month: 'numeric', 230 | day: 'numeric', 231 | hour: '2-digit', 232 | minute: '2-digit' 233 | }); 234 | const fileExt = this.getFileExtension(file.origin_file_name); 235 | const fileIcon = this.getFileIcon(fileExt); 236 | 237 | return ` 238 |
239 |
240 | ${fileIcon} 241 |
242 |
243 |
244 | 250 | ${this.escapeHtml(file.origin_file_name)} 251 | 252 | ${file.comment ? ` 253 |

254 | ${this.escapeHtml(file.comment)} 255 |

256 | ` : ''} 257 |
258 |
259 | 260 | ID: 261 | #${file.id} 262 | 263 | 264 | サイズ: 265 | ${fileSize}MB 266 | 267 | 268 | 日付: 269 | ${formattedDate} 270 | 271 | 272 | DL: 273 | ${file.count}回 274 | 275 |
276 |
277 | 295 |
296 | `; 297 | } 298 | 299 | renderFileCard(file) { 300 | const fileSize = (file.size / (1024 * 1024)).toFixed(1); 301 | const uploadDate = new Date(file.input_date * 1000); 302 | const formattedDate = uploadDate.toLocaleDateString('ja-JP', { 303 | year: 'numeric', 304 | month: 'numeric', 305 | day: 'numeric', 306 | hour: '2-digit', 307 | minute: '2-digit' 308 | }); 309 | const fileExt = this.getFileExtension(file.origin_file_name); 310 | const fileIcon = this.getFileIcon(fileExt); 311 | 312 | return ` 313 |
314 |
315 | 321 | ${fileIcon} ${this.escapeHtml(file.origin_file_name)} 322 | 323 | ${file.comment ? ` 324 |

325 | ${this.escapeHtml(file.comment)} 326 |

327 | ` : ''} 328 |
329 | 330 |
331 |
332 |
333 | 🆔 334 | ID 335 | #${file.id} 336 |
337 |
338 | 💾 339 | サイズ 340 | ${fileSize}MB 341 |
342 |
343 | 📅 344 | 日付 345 | ${formattedDate} 346 |
347 |
348 | ⬇️ 349 | DL数 350 | ${file.count} 351 |
352 |
353 | 354 | 370 |
371 |
372 | `; 373 | } 374 | 375 | renderPagination() { 376 | const totalPages = Math.ceil(this.filteredFiles.length / this.itemsPerPage); 377 | 378 | if (totalPages <= 1) { 379 | return ''; 380 | } 381 | 382 | const startItem = (this.currentPage - 1) * this.itemsPerPage + 1; 383 | const endItem = Math.min(this.currentPage * this.itemsPerPage, this.filteredFiles.length); 384 | 385 | let paginationHTML = ` 386 |
387 |
388 | ${startItem}-${endItem}件 (全${this.filteredFiles.length}件) 389 |
390 | 391 |
392 |
393 | 394 | 400 |
401 | 402 |
403 | `; 404 | 405 | // 前へボタン 406 | paginationHTML += ` 407 | 414 | `; 415 | 416 | // ページ番号ボタン 417 | const maxVisiblePages = 5; 418 | let startPage = Math.max(1, this.currentPage - Math.floor(maxVisiblePages / 2)); 419 | let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); 420 | 421 | if (endPage - startPage + 1 < maxVisiblePages) { 422 | startPage = Math.max(1, endPage - maxVisiblePages + 1); 423 | } 424 | 425 | if (startPage > 1) { 426 | paginationHTML += ``; 427 | if (startPage > 2) { 428 | paginationHTML += `...`; 429 | } 430 | } 431 | 432 | for (let i = startPage; i <= endPage; i++) { 433 | paginationHTML += ` 434 | 440 | `; 441 | } 442 | 443 | if (endPage < totalPages) { 444 | if (endPage < totalPages - 1) { 445 | paginationHTML += `...`; 446 | } 447 | paginationHTML += ``; 448 | } 449 | 450 | // 次へボタン 451 | paginationHTML += ` 452 | 459 | `; 460 | 461 | paginationHTML += ` 462 |
463 |
464 |
465 | `; 466 | 467 | return paginationHTML; 468 | } 469 | 470 | bindEvents() { 471 | // 検索イベント(デバウンス付き) 472 | let searchTimeout; 473 | this.container.addEventListener('input', (e) => { 474 | if (e.target.id === 'fileSearchInput') { 475 | clearTimeout(searchTimeout); 476 | this.searchQuery = e.target.value; 477 | 478 | // デバウンス処理(300ms)でパフォーマンス向上 479 | searchTimeout = setTimeout(() => { 480 | this.applyFilters(); 481 | this.render(); 482 | }, 300); 483 | } 484 | }); 485 | 486 | // ソート・表示件数変更イベント 487 | this.container.addEventListener('change', (e) => { 488 | if (e.target.id === 'fileSortSelect') { 489 | this.sortBy = e.target.value; 490 | this.applyFilters(); 491 | this.render(); 492 | } else if (e.target.id === 'itemsPerPageSelect') { 493 | this.itemsPerPage = parseInt(e.target.value); 494 | this.currentPage = 1; 495 | this.render(); 496 | } 497 | }); 498 | 499 | // クリック イベント 500 | this.container.addEventListener('click', (e) => { 501 | // 検索クリアボタン 502 | if (e.target.id === 'fileSearchClear') { 503 | this.searchQuery = ''; 504 | this.applyFilters(); 505 | this.render(); 506 | } 507 | // ビュー切り替えボタン 508 | else if (e.target.classList.contains('file-view-toggle__btn')) { 509 | const newView = e.target.dataset.view; 510 | if (newView && newView !== this.viewMode) { 511 | this.viewMode = newView; 512 | this.render(); 513 | 514 | // ビュー切り替えを localStorage に保存 515 | try { 516 | localStorage.setItem('fileManager_viewMode', this.viewMode); 517 | } catch (e) { 518 | // localStorage が使用できない場合は無視 519 | } 520 | } 521 | } 522 | // ページネーションボタン 523 | else if (e.target.classList.contains('file-pagination__btn') && !e.target.disabled) { 524 | const page = parseInt(e.target.dataset.page); 525 | if (page && page !== this.currentPage) { 526 | this.currentPage = page; 527 | this.render(); 528 | // ページ変更時にトップへスクロール 529 | this.container.scrollIntoView({ behavior: 'smooth', block: 'start' }); 530 | } 531 | } 532 | }); 533 | } 534 | 535 | // ユーティリティメソッド 536 | getFileExtension(filename) { 537 | return filename.split('.').pop() || ''; 538 | } 539 | 540 | getFileIcon(extension) { 541 | const iconMap = { 542 | // 画像 543 | 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', 'bmp': '🖼️', 'svg': '🖼️', 'webp': '🖼️', 544 | // 動画 545 | 'mp4': '🎬', 'avi': '🎬', 'mov': '🎬', 'wmv': '🎬', 'flv': '🎬', 'webm': '🎬', 'mkv': '🎬', 546 | // 音声 547 | 'mp3': '🎵', 'wav': '🎵', 'aac': '🎵', 'flac': '🎵', 'ogg': '🎵', 'm4a': '🎵', 548 | // ドキュメント 549 | 'pdf': '📕', 'doc': '📄', 'docx': '📄', 'txt': '📝', 'rtf': '📄', 550 | 'xls': '📊', 'xlsx': '📊', 'csv': '📊', 551 | 'ppt': '📊', 'pptx': '📊', 552 | // アーカイブ 553 | 'zip': '🗜️', 'rar': '🗜️', '7z': '🗜️', 'tar': '🗜️', 'gz': '🗜️', 554 | // コード 555 | 'html': '🌐', 'css': '🎨', 'js': '⚡', 'php': '🐘', 'py': '🐍', 'java': '☕', 'cpp': '🔧', 'c': '🔧', 556 | // その他 557 | 'exe': '⚙️', 'msi': '⚙️', 'dmg': '💽', 'iso': '💽' 558 | }; 559 | 560 | return iconMap[extension.toLowerCase()] || '📄'; 561 | } 562 | 563 | escapeHtml(text) { 564 | const div = document.createElement('div'); 565 | div.textContent = text; 566 | return div.innerHTML; 567 | } 568 | 569 | // 外部から呼び出し可能なメソッド 570 | refresh() { 571 | this.render(); 572 | } 573 | 574 | search(query) { 575 | this.searchQuery = query; 576 | this.applyFilters(); 577 | this.render(); 578 | } 579 | 580 | sort(sortBy) { 581 | this.sortBy = sortBy; 582 | this.applyFilters(); 583 | this.render(); 584 | } 585 | 586 | goToPage(page) { 587 | this.currentPage = page; 588 | this.render(); 589 | } 590 | } 591 | 592 | // グローバルに公開 593 | window.FileManager = FileManager; 594 | --------------------------------------------------------------------------------