OSDN Git Service

initial master
authorseraphy <seraphy@users.osdn.me>
Mon, 17 Dec 2018 08:45:46 +0000 (17:45 +0900)
committerseraphy <seraphy@users.osdn.me>
Mon, 17 Dec 2018 08:45:46 +0000 (17:45 +0900)
23 files changed:
.gitattributes [new file with mode: 0644]
.gitignore [new file with mode: 0644]
cgi-bin/access_ctrl.php [new file with mode: 0644]
cgi-bin/calc_trip.php [new file with mode: 0644]
cgi-bin/common.php [new file with mode: 0644]
cgi-bin/delete.php [new file with mode: 0644]
cgi-bin/direct_download.php [new file with mode: 0644]
cgi-bin/download.php [new file with mode: 0644]
cgi-bin/info.php [new file with mode: 0644]
cgi-bin/makedata.php [new file with mode: 0644]
cgi-bin/partslist.php [new file with mode: 0644]
cgi-bin/read_catalog.php [new file with mode: 0644]
cgi-bin/report.php [new file with mode: 0644]
cgi-bin/taglist.php [new file with mode: 0644]
cgi-bin/upload.php [new file with mode: 0644]
cgi-bin/zipinfo.php [new file with mode: 0644]
cgi-bin/ziplist.php [new file with mode: 0644]
dbupdate.sh [new file with mode: 0755]
htdocs/oauth_redirect.php [new file with mode: 0644]
htdocs/upload.css [new file with mode: 0644]
htdocs/upload.html [new file with mode: 0644]
htdocs/upload.js [new file with mode: 0644]
movetozips.sh [new file with mode: 0644]

diff --git a/.gitattributes b/.gitattributes
new file mode 100644 (file)
index 0000000..d0d4000
--- /dev/null
@@ -0,0 +1,4 @@
+*.html  eol=crlf
+*.css   eol=crlf
+*.js    eol=crlf
+*.php   eol=crlf
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..37decad
--- /dev/null
@@ -0,0 +1,5 @@
+/.vscode
+/cgi-bin/*.db
+/cgi-bin/*.log
+/zip
+cgi-bin/secret.php
diff --git a/cgi-bin/access_ctrl.php b/cgi-bin/access_ctrl.php
new file mode 100644 (file)
index 0000000..28ebc64
--- /dev/null
@@ -0,0 +1,178 @@
+<?php
+require_once __DIR__ . '/common.php';
+
+# secret.phpは以下の項目を設定する
+# OSDNのAPIのクライアントキーとシークレットキー
+#const OSDN_CMJ_CLIENTKEY = '';
+#const OSDN_CMJ_SECRETKEY = '';
+# クライアントに保持するクッキーの妥当性検証のためのSID_KEY
+#const CMD_UPLOADER_SID_KEY = '';
+if (file_exists(__DIR__ . '/secret.php')) {
+    include_once __DIR__ . '/secret.php';
+} else {
+    define('CMD_UPLOADER_SID_KEY', 'dNOMYxOyJG8NyMx113Wt0OHaeL/W/rNf');
+}
+
+
+/**
+ * クライアント(リモートIP)からのアクセスの可否を制御するクラス
+ */
+class AccessCtrl {
+
+    private $pdo;
+
+    /**
+     * コンストラクタ
+     * @param pdo $pdo
+     */
+    public function __construct($pdo) {
+        $this->pdo = $pdo;
+    }
+
+    /**
+     * 24時間以上経過したリモートIPの制御は削除する。
+     */
+    public function purge_expired_entries() {
+        $stm = $this->pdo->prepare("delete from REMOTE_ENTRIES where REGDATE1 < datetime('now', '-1 days')");
+        $stm->execute();
+        $stm = $this->pdo->prepare("delete from REMOTE_ENTRIES where FAILDATE < datetime('now', '-1 days')");
+        $stm->execute();
+    }
+
+    /**
+     * COOKIEに設定されたSIDが期限切れでなく改ざんされていないことを確認する。
+     * 期限内であり改ざんされていない場合のみtrueを返す。
+     * COKKIEにSIDがない場合、もしくは期限切れ、改ざんされている場合はFalseを返す。
+     * @return COKKIEに有効なSIDがある場合はTrue、そうでなければFalse
+     */
+    public function checkValidSid() {
+        if (isset($_COOKIE['cmj-uploader-sid'])) {
+            $sid = $_COOKIE['cmj-uploader-sid'];
+
+            @list($credential_json_b64, $credential_hmac_b64) = explode('.', $sid);
+            if (!isset($credential_hmac_b64)) {
+                // ドットがない
+                return false;
+            }
+
+            $credential_json = base64url_decode($credential_json_b64);
+            $credential_hmac_b64_v = base64url_encode(hash_hmac('sha256', $credential_json, CMD_UPLOADER_SID_KEY, true));
+            if ($credential_hmac_b64_v !== $credential_hmac_b64) {
+                // 改ざんされている場合
+                return false;
+            }
+            $credential = json_decode($credential_json, true);
+            //append_log('credential: ' . var_export($credential, true) . ', time=' . time());
+
+            if ($credential['expired'] > time()) {
+                // 期限切れでない場合
+                append_log('valid login_id:' . $credential['id']);
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * リモートアドレスを登録する。
+     * 一定時間内(1h)に所定5回数を超えて登録しようとした場合はFalseを返す。
+     * @param string $remoteaddr リモートアドレス
+     * @return 登録できた場合はTrue、そうでなければFalse
+     */
+    public function register($remoteaddr) {
+        if ($this->checkValidSid()) {
+            return true;
+        }
+
+        $stm = $this->pdo->prepare('update REMOTE_ENTRIES set
+        REGDATE1 = CURRENT_TIMESTAMP,
+        REGDATE2 = REGDATE1,
+        REGDATE3 = REGDATE2,
+        REGDATE4 = REGDATE3,
+        REGDATE5 = REGDATE4
+        where REMOTE_ADDR = ?
+        ');
+        $stm->execute([$remoteaddr]);
+
+        if ($stm->rowCount() == 0) {
+            // 初回登録の場合
+            $stm = $this->pdo->prepare('insert into REMOTE_ENTRIES(regdate1, remote_addr) values(CURRENT_TIMESTAMP, ?)');
+            $stm->execute([$remoteaddr]);
+            $this->purge_expired_entries();
+            return true;
+        }
+
+        $stm = $this->pdo->prepare("select regdate5 < datetime('now', '-1 hours') from REMOTE_ENTRIES where remote_addr = ?");
+        $stm->execute([$remoteaddr]);
+        $regdate5 = $stm->fetchColumn();
+        if ($regdate5 == null || $regdate5 == 1) {
+            // まだ5回登録していないか、1時間以上前であれば可
+            return true;
+        }
+        // 1時間以内に5回登録しているか、もしくはエラー
+        return false;
+    }
+
+    /**
+     * 指定されたリモートアドレスで失敗した回数を取得する
+     * @param string $remoteaddr リモートアドレス
+     * @return 失敗回数
+     */
+    public function getFailCount($remoteaddr) {
+        $this->purge_expired_entries();
+        $stm = $this->pdo->prepare("select failcount, faildate > datetime('now', '-10 minutes') active 
+            from REMOTE_ENTRIES where remote_addr = ?");
+        $stm->execute([$remoteaddr]);
+        $row = $stm->fetch();
+        if ($row !== false) {
+            $failcount = $row['failcount'];
+            $active = $row['active'];
+            if ($failcount != null && $active != null && $active == 1) {
+                return $failcount;
+            }
+        }
+        return 0;
+    }
+
+    /**
+     * 指定したリモートアドレスでの失敗回数を+1する。
+     * @param string $remoteaddr リモートアドレス
+     * @return +1された失敗回数
+     */
+    public function failIncrement($remoteaddr) {
+        $this->purge_expired_entries();
+
+        $stm = $this->pdo->prepare('update REMOTE_ENTRIES set failcount = failcount + 1 where remote_addr = ?');
+        $stm->execute([$remoteaddr]);
+        if ($stm->rowCount() == 0) {
+            // リモートアドレスの登録がない場合は新規カウントする
+            $stm = $this->pdo->prepare('insert into REMOTE_ENTRIES(failcount, faildate, remote_addr) values(1, CURRENT_TIMESTAMP, ?)');
+            $stm->execute([$remoteaddr]);
+            return 1;
+        }
+
+        $stm = $this->pdo->prepare("select failcount, faildate > datetime('now', '-10 minutes') active 
+            from REMOTE_ENTRIES where REMOTE_ADDR = ?");
+        $stm->execute([$remoteaddr]);
+
+        $row = $stm->fetch();
+        $failcount = 0;
+        $active = null;
+        if ($row !== false) {
+            $failcount = $row['failcount'];
+            $active = $row['active'];
+        }
+        $row = null;
+
+        if ($failcount == null || $active == null || $active != 1) {
+            // 10分以内のアクセスが記録されていなければ、現時刻からカウントしなおす
+            $stm = $this->pdo->prepare(
+                'update REMOTE_ENTRIES set failcount = 1, faildate = CURRENT_TIMESTAMP where remote_addr = ?');
+            $stm->execute([$remoteaddr]);
+            return 1;
+        }
+
+        return $failcount;
+    }
+}
+?>
\ No newline at end of file
diff --git a/cgi-bin/calc_trip.php b/cgi-bin/calc_trip.php
new file mode 100644 (file)
index 0000000..1012056
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+require_once __DIR__ . '/common.php';
+
+/**
+ * Author文字列中に#がある場合は、#以降をトリップキーとして取り出す。
+ * トリップがない場合は空文字を返す。
+ * @param string $author トリップキーを含む可能性のある作者名
+ * @return トリップキー、もしくは空文字(トリップキーは#で始まる)
+ */
+function get_trip_pass($author) {
+    $pos = strpos($author, '#');
+    if ($pos === false) {
+        return "";
+    }
+    $passphrase = substr($author, $pos);
+    return $passphrase;
+}
+
+/**
+ * Author文字列中に#がある場合は、#以降をトリップキーとして
+ * 一方向ハッシュ化した文字列に変換して返す。
+ * トリップがない場合は、作者名をそのまま返す。
+ * @param string $author 作者名
+ * @return トリップキーがあればトリップを付与した作者名、そうでなければ、そのまま
+ */
+function get_display_author_name($author) {
+    $pos = strpos($author, '#');
+    $trip = '';
+    $name = $author;
+    if ($pos !== false) {
+        $name = substr($author, 0, $pos);
+        $passphrase = substr($author, $pos); // (トリップキーは#で始まる)
+        $trip = '#' . substr(calc_trip($passphrase), 0, 12); // トリップ値を先頭12文字に制限
+    }
+    return $name . $trip;
+}
+
+/**
+ * トリップキーから表示用の一方向ハッシュに変換した文字列を取得する
+ * @param string $passphrase トリップキー
+ * @return トリップを返す。
+ */
+function calc_trip($passphrase) {
+    return base64_encode(sha1(TRIPSALT . $passphrase));
+}
+?>
diff --git a/cgi-bin/common.php b/cgi-bin/common.php
new file mode 100644 (file)
index 0000000..1ab95e3
--- /dev/null
@@ -0,0 +1,177 @@
+<?php
+/**
+ * 1ファイルの最大のコンテンツサイズ
+ */
+const MAX_CONTENT_SIZE =  800000;
+
+/**
+ * PNGファイルの最大サイズ
+ */
+const MAX_PNG_SIZE     = 1000000;
+
+/**
+ * データベース名
+ */
+const DB_NAME = __DIR__ . '/mydb.db';
+
+/**
+ * ログファイル名
+ */
+const LOGFILE = __DIR__ . '/entries.log';
+
+/**
+ * ZIPファイル格納先
+ */
+const ZIPDIR = __DIR__ . '/../zip/';
+
+/**
+ * トリップに使うソルト
+ */
+const TRIPSALT = '#TRIPSALT#';
+
+/**
+ * ホストアドレスのハッシュに使うソルト
+ */
+const HOSTADDR_SALT = '@@IP@@';
+
+/**
+ * 最大の失敗回数
+ */
+const MAX_FAIL_COUNT = 5;
+
+/**
+ * データベースオブジェクトを返す。
+ * FETCH_ASSOC, ERRMODE_EXCEPTIONが設定される。
+ */
+function create_pdo() {
+    // sqlite3のdbファイルをグループでの読み書き可能にする
+    $db_path = DB_NAME;
+    touch($db_path);
+    chmod($db_path, 0664);
+
+    $pdo = new PDO('sqlite:' . $db_path);
+    // SQL実行時にもエラーの代わりに例外を投げるように設定
+    // (毎回if文を書く必要がなくなる)
+    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+
+    // テーブル作成
+
+    // レポート(通報)
+    // 通報内容を記録する。
+    $pdo->exec("CREATE TABLE IF NOT EXISTS REPORT_ENTRIES(
+        zipid INTEGER NOT NULL,
+        remote_addr VARCHAR NOT NULL,
+        report_type VARCHAR,
+        comment VARCHAR,
+        primary key (zipid, remote_addr)
+    )");
+
+    // リモートアドレス
+    // アップロードしてきたリモートアドレスを管理する
+    $pdo->exec("CREATE TABLE IF NOT EXISTS REMOTE_ENTRIES(
+        remote_addr VARCHAR PRIMARY KEY,
+        regdate1 TIMESTAMP,
+        regdate2 TIMESTAMP,
+        regdate3 TIMESTAMP,
+        regdate4 TIMESTAMP,
+        regdate5 TIMESTAMP,
+        faildate TIMESTAMP,
+        failcount INTEGER
+    )");
+
+    // ZIPファイル一覧
+    $pdo->exec("CREATE TABLE IF NOT EXISTS ZIP_ENTRIES(
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        fname VARCHAR UNIQUE NOT NULL,
+        fsize INTEGER,
+        href VARCHAR,
+        title VARCHAR,
+        author VARCHAR,
+        delkey VARCHAR,
+        license VARCHAR,
+        comment VARCHAR,
+        regdate TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+        hostaddr VARCHAR,
+        downloaded INTEGER DEFAULT 0
+    )");
+    
+    // タグ一覧
+    $pdo->exec("CREATE TABLE IF NOT EXISTS TAG_ENTRIES(
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        tagname VARCHAR UNIQUE NOT NULL,
+        sticky INTEGER DEFAULT 0,
+        regdate TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+        hostaddr VARCHAR
+    )");
+
+    // ZIPファイルが保持するパーツ一覧
+    $pdo->exec("CREATE TABLE IF NOT EXISTS PARTS_ENTRIES(
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        zipid INTEGER NOT NULL,
+        partsname VARCHAR
+    )");
+    $pdo->exec("CREATE INDEX IF NOT EXISTS PARTS_ENTRIES_IX ON PARTS_ENTRIES(zipid)");
+    
+    // パーツとタグとの関連テーブル
+    $pdo->exec("CREATE TABLE IF NOT EXISTS TAG_PARTS_REL(
+        tagid INTEGER NOT NULL,
+        partsid INTEGER NOT NULL,
+        sticky INTEGER DEFAULT 0
+    )");
+    $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS TAG_PARTS_REL_IX1 ON TAG_PARTS_REL(tagid, partsid)");
+    $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS TAG_PARTS_REL_IX2 ON TAG_PARTS_REL(partsid, tagid)");
+    $pdo->exec("CREATE TABLE IF NOT EXISTS TAG_ZIP_REL(
+        tagid INTEGER NOT NULL,
+        zipid INTEGER NOT NULL,
+        sticky INTEGER DEFAULT 0
+    )");
+    $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS TAG_ZIP_REL_IX1 ON TAG_ZIP_REL(tagid, zipid)");
+    $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS TAG_ZIP_REL_IX1 ON TAG_ZIP_REL(zipid, tagid)");
+    return $pdo;
+}
+
+/**
+ * リモートアドレスを取得する。
+ * プロキシ系経由である場合は、その双方が@で接続されて表記される。
+ * @return string リモートアドレス
+ */
+function get_hostaddr() {
+    if (array_key_exists('HTTP_X_FORWARDED_FOR', $_SERVER)) {
+        return $_SERVER['HTTP_X_FORWARDED_FOR'] . '@' . $_SERVER['REMOTE_ADDR'];
+    }
+    return $_SERVER['REMOTE_ADDR'];
+}
+
+/**
+ * ログファイルにメッセージを追記する。
+ * 行の先頭には日付(GMT)とリモートアドレスが記録される。
+ * 書き込みは排他的に行われる。
+ * @param string メッセージ
+ */
+function append_log($msg) {
+    $hostaddr = get_hostaddr();
+    $date = gmdate('Y-m-d H:i:s');
+    $path = LOGFILE;
+    file_put_contents($path, $date . ' ' . $hostaddr . ' ' . $msg . PHP_EOL , FILE_APPEND | LOCK_EX);
+}
+
+/**
+ * URLセーフなBASE64変換.
+ * https://qiita.com/YamasakiKenta/items/7ea2579a5e99477f950a
+ * @param string $data
+ * @return URLセーフなbase64文字列
+ */ 
+function base64url_encode($data) { 
+    return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); 
+} 
+
+/**
+ * URLセーフなBASE64のデコード
+ * @param string $data URLセーフなbase64文字列
+ * @return デコードされたデータ
+ */
+function base64url_decode($data) { 
+    return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT)); 
+} 
+?>
\ No newline at end of file
diff --git a/cgi-bin/delete.php b/cgi-bin/delete.php
new file mode 100644 (file)
index 0000000..dcecbf6
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+////////////////////////////////////////
+// アップロードファイルの削除CGI
+////////////////////////////////////////
+require_once __DIR__ . '/common.php';
+require_once __DIR__ . '/access_ctrl.php';
+require_once __DIR__ . '/calc_trip.php';
+require_once __DIR__ . '/read_catalog.php';
+
+header("Content-type: application/json; charset=utf-8");
+
+$result = ['result' => 'error'];
+
+// パラメータの取り出し
+
+$selected_id = isset($_POST['selected_id']) ? $_POST['selected_id'] : '';
+$verifydelkey = isset($_POST['verifydelkey']) ? $_POST['verifydelkey'] : '';
+$verifytrip = isset($_POST['verifytrip']) ? $_POST['verifytrip'] : '';
+$keytype = isset($_POST['keytype']) ? $_POST['keytype'] : '';
+
+$hostaddr = get_hostaddr();
+
+// データベースの検索
+$pdo = create_pdo();
+
+// アクセス制限中か? (所定時間経過で自動解除)
+$accessCtrl = new AccessCtrl($pdo);
+if ($accessCtrl->getFailCount($hostaddr) > MAX_FAIL_COUNT) {
+    $result = [
+        'result' => 'error',
+        'message' => '試行回数が上限値を超えました。しばらくお待ちください。'
+    ];
+    echo json_encode($result);
+    exit;
+}
+
+$fetch = $pdo->prepare('select fname, author, delkey from ZIP_ENTRIES where id = ?');
+$fetch->execute([$selected_id]);
+$row = $fetch->fetch();
+$fetch = null;
+
+if ($row === false) {
+    $result = [
+        'result' => 'ok',
+        'message' => 'already deleted.'
+    ];
+} else {
+    $fname = $row['fname'];
+    $author = $row['author'];
+    $delkey = $row['delkey'];
+
+    // トリップキーまたは削除キーの一致の確認
+    $valid_key = false;
+    if ($keytype === 'TRIP') {
+        // トリップの場合
+        $db_passphrase = get_trip_pass($author);
+        $valid_key = ($db_passphrase !== '') && ($db_passphrase === "#$verifytrip");
+    } else {
+        // 削除キーの場合
+        $valid_key = ($delkey === $verifydelkey);
+    }
+    
+    if ($valid_key) {
+        // トリップキーまたは削除キーが一致した場合
+        append_log("delete ${selected_id}");
+
+        $stm = $pdo->prepare('delete from ZIP_ENTRIES where id = ?');
+        $stm->execute([$selected_id]);
+        $stm = null;
+
+        // タグ情報の削除
+        $pc = new parse_catalog();
+        $pc->unregister($selected_id);
+
+        $result = [
+            'result' => 'ok'
+        ];
+
+    } else {
+        // キーの不一致によるエラー
+        // 試行回数+1する
+        append_log("unmatch delkey. zipid:${selected_id}");
+        $accessCtrl->failIncrement($hostaddr);
+        $result = [
+            'result' => 'error',
+            'message' => 'Authentication Failed'
+        ];
+    }
+}
+$accessCtrl = null;
+$pdo = null;
+
+echo json_encode($result);
+?>
\ No newline at end of file
diff --git a/cgi-bin/direct_download.php b/cgi-bin/direct_download.php
new file mode 100644 (file)
index 0000000..9d7acc7
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+/**
+ * 指定したURLの内容を現在の出力ストリームにそのまま出力する.
+ * URLに対するサーバーからのコンテンツタイプの設定が返されない場合はapplication/octet-streamとする。
+ * 指定したURLがエラーコードを返した場合は、そのエラーコードを返す.
+ * @param $url ダウンロード先URL
+ */
+function download_direct_url($url)
+{
+    $curl = curl_init();
+    if ($curl === false) {
+        header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
+        exit;
+    }
+    
+    curl_setopt($curl, CURLOPT_URL, $url);
+    
+    // wget, curl, poweshellのagent以外からだとダウンロードページを表示するサービス対策
+    curl_setopt($curl, CURLOPT_HTTPHEADER, [
+        'User-Agent: Wget/1.16 (linux-gnu)'
+    ]);
+    
+    // リダイレクトの有効化、回数制限、リファラの自動設定
+    curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
+    curl_setopt($curl, CURLOPT_MAXREDIRS, 10);
+    curl_setopt($curl, CURLOPT_AUTOREFERER, true);
+    
+    // SSL対応
+    curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
+    
+    // HEAD取得
+    curl_setopt($curl, CURLOPT_NOBODY, true);
+    $res = curl_exec($curl);
+    
+    // 結果コード取得
+    $code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+    if ($code >= 200 && $code < 300) {
+        // コンテントタイプの設定 (なければapplication/octet-stream扱いとする)
+        $contentType = curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
+        if ($contentType == null) {
+            $contentType = 'application/octet-stream';
+        }
+        header('Content-Type: ' . $contentType);
+    
+        // ファイル名付与
+        $urlinfo = parse_url($url);
+        if ($urlinfo !== false) {
+            $fname = basename($urlinfo['path']);
+            header('Content-Disposition: attachment; filename="' . $fname . '"');
+        }
+    
+        // コンテンツの長さの設定、なければ何もしない
+        $len = curl_Getinfo($curl, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
+        if ($len !== false && $len > 0) {
+            header('Content-Length: ' . $len);
+        }
+
+        // GETアクセスして結果を転送する
+        curl_setopt($curl, CURLOPT_NOBODY, false);
+        curl_exec($curl);
+    } else {
+        // サーバーステータスコードを、そのまま返す
+        header('HTTP', true, $code);
+        echo 'ERROR ', $code, PHP_EOL;
+    }
+    
+    curl_close($curl);
+}
diff --git a/cgi-bin/download.php b/cgi-bin/download.php
new file mode 100644 (file)
index 0000000..fc5018c
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+////////////////////////////////////////
+// ファイルのダウンロードCGI
+////////////////////////////////////////
+require_once __DIR__ . '/common.php';
+require_once __DIR__ . '/direct_download.php';
+
+$selected_id = isset($_GET['id']) ? $_GET['id'] : '';
+
+$pdo = create_pdo();
+
+// 件数取得
+$fetch = $pdo->prepare('select fname, fsize, href from ZIP_ENTRIES where id = ?');
+$fetch->execute([$selected_id]);
+$row = $fetch->fetch();
+if ($row === false) {
+    header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found', true, 404);
+    exit;
+}
+$fname = $row['fname'];
+$fsize = $row['fsize'];
+$href = $row['href'];
+$fetch = null;
+
+$file = ZIPDIR . $fname;
+if ($href) {
+    header("x-storage-location: ${href}");
+    download_direct_url($href);
+
+} else {
+    header('Content-type: application/octet-stream');
+    header('Content-Length: ' . filesize($file));
+    $res = @readfile($file);
+    if ($res === false) {
+        append_log("download error: ${res}");
+    }
+}
+
+$upd = $pdo->prepare('update ZIP_ENTRIES set downloaded = downloaded + 1 where id = ?');
+$upd->execute([$selected_id]);
+$upd = null;
+?>
\ No newline at end of file
diff --git a/cgi-bin/info.php b/cgi-bin/info.php
new file mode 100644 (file)
index 0000000..c9f5eeb
--- /dev/null
@@ -0,0 +1 @@
+<?php phpinfo(); ?>
\ No newline at end of file
diff --git a/cgi-bin/makedata.php b/cgi-bin/makedata.php
new file mode 100644 (file)
index 0000000..4103353
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+const DB_NAME = 'mydb.db';
+
+$pdo = new PDO('sqlite:' . DB_NAME);
+// SQL実行時にもエラーの代わりに例外を投げるように設定
+// (毎回if文を書く必要がなくなる)
+$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+
+// テーブル作成
+$pdo->exec("CREATE TABLE IF NOT EXISTS ZIP_ENTRIES(
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    fname VARCHAR,
+    fsize INTEGER,
+    title VARCHAR,
+    author VARCHAR,
+    delkey VARCHAR,
+    license VARCHAR,
+    comment VARCHAR,
+    regdate TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    hostaddr VARCHAR
+)");
+
+$rows = [];
+$mx = 1000;
+for ($idx = 0; $idx < $mx; $idx++) {
+    $rows[] = [
+        'fname' => "<name>&<${idx}>",
+        'fsize' => $idx * 100,
+        'title' => "<title>&<${idx}>",
+        'author' => "<author>&<${idx}>",
+        'comment' => "<comment>&<${idx}>",
+        'license' => 'xxxx'
+    ];
+}
+
+
+$stm = $pdo->prepare("insert into ZIP_ENTRIES(fname, fsize, title, author, license, comment)
+    values (:fname, :fsize, :title, :author, :license, :comment)");
+foreach ($rows as $row) {
+    $stm->execute($row);
+}
+
+$stm = null;
+$pdo = null;
+?>
\ No newline at end of file
diff --git a/cgi-bin/partslist.php b/cgi-bin/partslist.php
new file mode 100644 (file)
index 0000000..911e4d7
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+//////////////////////////////////////////////
+// 指定したzipに対するパーツ一覧(JSON)を取得するCGI
+//////////////////////////////////////////////
+require_once __DIR__ . '/common.php';
+require_once __DIR__ . '/calc_trip.php';
+
+header("Content-type: application/json; charset=utf-8");
+
+// DataGridのページャのページサイズとページ範囲、ソートカラム、順序等の情報を取得する
+$page = intval(isset($_POST['page']) ? $_POST['page'] : 1);
+$pageSize = intval(isset($_POST['rows']) ? $_POST['rows'] : 10);
+$sortColumn = isset($_POST['sort']) ? $_POST['sort'] : 'id';
+$sortOrder = isset($_POST['order']) ? ($_POST['order'] == 'asc' ? 'asc' : 'desc') : 'asc';
+$tzoffset = intval(isset($_POST['tzoffset']) ? $_POST['tzoffset'] : 0);
+$tagids = isset($_POST['tagids']) ? $_POST['tagids'] : [];
+
+// パラメータの妥当性チェック
+$valid_columns = [
+    'id', 'partsid', 'partsname', 'title', 'author', 'fname', 'fsize', 'regdate', 'downloaded', 'license'
+];
+if (!in_array($sortColumn, $valid_columns)) {
+    // 妥当なカラム名でなければサーバーエラーを返す
+    header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
+    exit;
+}
+
+$strtagids = ',' . implode(',', $tagids) . ',';
+
+$pdo = create_pdo();
+
+// パーツ別タグ関係、ZIP別タグ関係から指定したtagidを含む全てのパーツリストを、そのパーツのZIPの情報とともに列挙する。
+// (同一のpartsidがtagidにマッチした回数だけ繰り返し出現する。)
+$fetch = $pdo->prepare("select tagid, zip_entries.id id, partsid, partsname, title,
+    author, fname, fsize, regdate, downloaded, license from parts_entries
+    inner join zip_entries on parts_entries.zipid = zip_entries.id inner join (
+    select tagid, partsid from tag_parts_rel where instr(:strtagids, ',' || tagid || ',') <> 0
+    union all
+    select tagid, parts_entries.id partsid from tag_zip_rel
+    inner join parts_entries on tag_zip_rel.zipid = parts_entries.zipid where instr(:strtagids, ',' || tagid || ',') <> 0
+    ) C on parts_entries.id = c.partsid
+    where not exists (select * from report_entries r where r.zipid = zip_entries.id and r.remote_addr = :remote_addr)");
+$fetch->execute([
+    'strtagids' => $strtagids,
+    'remote_addr' => get_hostaddr()
+]);
+
+$rows = $fetch->fetchAll();
+if ($rows === false) {
+    // 読み取りに失敗した場合はサーバーエラーとする
+    header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
+    exit;
+}
+
+$fetch = null;
+$pdo = null;
+
+// tagidが異なる同一のpartsidが繰り返されるレコードリストから
+// partsidごとのtagidのリストを構築して、要求時のtagidsをすべて満たすパーツのレコードだけを返す
+function filterMatchAll($rows, $tagids) {
+    $partsMap = [];
+    $partsTagMap = [];
+    foreach ($rows as $row) {
+        $partsid = $row['partsid'];
+        $tagid = $row['tagid'];
+    
+        if (array_key_exists($partsid, $partsTagMap)) {
+            $partsTagMap[$partsid][] = $tagid;
+        } else {
+            $partsMap[$partsid] = $row;
+            $partsTagMap[$partsid] = [$tagid];
+        }
+    }
+    
+    $filteredRows = [];
+    foreach ($partsTagMap as $partsId => $tags) {
+        if (count(array_intersect($tags, $tagids)) == count($tagids)) { // 配列の中身が一致
+            $row = $partsMap[$partsId];
+            unset($row['tagid']); // tagidは削除しておく
+            $filteredRows[] = $row;
+        }
+    }
+    return $filteredRows;
+}
+$filteredRows = filterMatchAll($rows, $tagids);
+
+// 指定したカラムでソートする
+$sortkeys = [];
+foreach ($filteredRows as $row) {
+    $sortkeys[] = array_key_exists($sortColumn, $row) ? $row[$sortColumn] : null;
+}
+array_multisort($sortkeys, $sortOrder == 'asc' ? SORT_ASC : SORT_DESC, $filteredRows);
+
+$startPos = ($page - 1) * $pageSize;
+$result = [
+    'total' => count($filteredRows),
+    'rows' => array_slice($filteredRows, $startPos, $pageSize) // ページャの範囲で返す
+];
+echo json_encode($result);
+?>
\ No newline at end of file
diff --git a/cgi-bin/read_catalog.php b/cgi-bin/read_catalog.php
new file mode 100644 (file)
index 0000000..855402b
--- /dev/null
@@ -0,0 +1,255 @@
+<?php
+@require_once __DIR__ . '/common.php';
+
+/**
+ * カタログファイルを分析してタグを更新するためのクラス
+ */
+class parse_catalog {
+
+    /**
+     * 既知のタグ
+     */
+    private $known_tags = [];
+
+    /**
+     * データベースアクセス
+     */
+    private $pdo;
+
+    /**
+     * リモートアドレス
+     */
+    public $hostaddr;
+
+    /**
+     * タグ名をデータベースに登録しIDを返す。すでに登録されていれば、そのIDを返す。
+     * @param $tag タグ名
+     * @return タグID
+     */
+    function ensure_tag($tag) {
+        $tag = trim($tag);
+        if (!array_key_exists($tag, $this->known_tags)) {
+            $stm = $this->pdo->prepare('select id from tag_entries where tagname = ?');
+            $stm->execute([$tag]);
+            $tagid = $stm->fetchColumn();
+            if ($tagid === false) {
+                $ins = $this->pdo->prepare('insert into tag_entries(tagname, hostaddr, sticky)
+                    values(:tagname, :hostaddr, 1)');
+                $ins->execute([
+                    'tagname' => $tag,
+                    'hostaddr' => $this->hostaddr
+                    ]);
+                $tagid = $this->pdo->lastInsertId();
+            }
+            $this->known_tags[$tag] = $tagid;
+        } else {
+            $tagid = $this->known_tags[$tag];
+        }
+        return $tagid;
+    }
+
+    /**
+     * ZIPに関連づけられているパーツとタグ、ZIPとタグの関連と、パーツ一覧を削除する.
+     * @param $zipid ZIPエントリのID
+     */
+    function clean_parts($zipid) {
+        $stm = $this->pdo->prepare('delete from TAG_ZIP_REL where zipid = ?');
+        $stm->execute([$zipid]);
+        $stm = $this->pdo->prepare('delete from TAG_PARTS_REL where TAG_PARTS_REL.partsid in (
+            select PARTS_ENTRIES.id from PARTS_ENTRIES where PARTS_ENTRIES.zipid = ?)');
+        $stm->execute([$zipid]);
+        $stm = $this->pdo->prepare('delete from PARTS_ENTRIES where zipid = ?');
+        $stm->execute([$zipid]);
+        $stm = null;
+    }
+
+    /**
+     * パーツにもZIPにも関連づけられていないタグがあれば、そのタグを削除する.
+     */
+    function purge_unused_tags() {
+        $stm = $this->pdo->prepare('select tagid, sum(cnt) sumcnt from (
+            select a.id tagid, count(b.tagid) cnt from tag_entries a left outer join tag_parts_rel b on a.id = b.tagid group by a.id
+            union all
+            select a.id tagid, count(b.tagid) cnt from tag_entries a left outer join tag_zip_rel b on a.id = b.tagid group by a.id
+            ) group by tagid having sumcnt < 1');
+        $stm->execute();
+        $rows = $stm->fetchAll();
+        $stm = null;
+        if ($rows !== false) {
+            $delstm = $this->pdo->prepare('delete from tag_entries where id = ?');
+            foreach ($rows as $row) {
+                $tagid = $row['tagid'];
+                $delstm->execute([$tagid]);
+            }
+            $delstm = null;
+        }
+    }
+
+    /**
+     * ZIPに関連づけられたパーツを登録しIDを返す。すでに登録されていれば、そのIDを返す。
+     * @param $zipid ZIPエントリのID
+     * @param $partsname パーツ名
+     * @return パーツのID
+     */
+    function ensure_partsname($zipid, $partsname) {
+        $stm = $this->pdo->prepare(
+            'select id from parts_entries where zipid = :zipid and partsname = :partsname');
+        $stm->execute([
+            'zipid' => $zipid,
+            'partsname' => $partsname
+        ]);
+        $partsid = $stm->fetchColumn();
+        $stm = null;
+
+        if ($partsid === false) {
+            $ins = $this->pdo->prepare(
+                'insert into parts_entries(zipid, partsname) values(:zipid, :partsname)');
+            $ins->execute([
+                'zipid' => $zipid,
+                'partsname' => $partsname
+            ]);
+            $partsid = $this->pdo->lastInsertId();
+            $ins = null;
+        }
+
+        return $partsid;
+    }
+
+    /**
+     * パーツのタグを登録する。すでに登録済みであれば何もしない。
+     * @param $partsid パーツID
+     * @param $tagid タグID
+     */
+    function register_parts_tag($partsid, $tagid) {
+        $stm = $this->pdo->prepare('insert into TAG_PARTS_REL(partsid, tagid) 
+            select :partsid, :tagid where not exists (
+                select * from TAG_PARTS_REL A where A.partsid = :partsid and A.tagid = :tagid)');
+        $stm->execute([
+            'partsid' => $partsid,
+            'tagid' => $tagid
+        ]);
+    }
+
+    /**
+     * ZIPのタグを登録する。すでに登録済みであれば何もしない。
+     * @param $partsid パーツID
+     * @param $tagid タグID
+     */
+    function register_zip_tag($zipid, $tagid) {
+        $stm = $this->pdo->prepare('insert into TAG_ZIP_REL(zipid, tagid) 
+            select :zipid, :tagid where not exists (
+                select * from TAG_ZIP_REL A where A.zipid = :zipid and A.tagid = :tagid)');
+        $stm->execute([
+            'zipid' => $zipid,
+            'tagid' => $tagid
+        ]);
+    }
+
+    /**
+     * 指定したzipidのタグ情報を削除し、
+     * その後、どこにも使われていないタグがあれば、それを削除する。
+     * @param zipid ZIPファィルのID
+     */
+    public function unregister($zipid) {
+        $this->pdo = create_pdo();
+        $this->pdo->beginTransaction();
+        try {
+            $this->clean_parts($zipid);
+            $this->purge_unused_tags();
+            $this->pdo->commit();
+            $this->pdo = null;
+
+        } catch (Exception $e) {
+            $this->pdo->rollBack();
+            $this->pdo = null;
+            throw $e;
+        }
+    }
+
+    /**
+     * カタログの文字列リストを解析する
+     * @param $lines カタログの文字列のリスト
+     * @param $zipid ZIPエントリのID
+     */
+    public function register($lines, $zipid) {
+        $this->pdo = create_pdo();
+        $this->pdo->beginTransaction();
+        try {
+            $this->clean_parts($zipid);
+
+            foreach ($lines as $line) {
+                $line = trim($line);
+                $pos = strrpos($line, '=');
+                if ($pos !== false) {
+                    $partsname = substr($line, 0, $pos); // パーツ名別タグ
+                    $partsname = trim($partsname);
+                    $partsid = 0;
+                    if (strlen($partsname) > 0) {
+                        $partsid = $this->ensure_partsname($zipid, $partsname);
+                    }
+                    $tags = explode(',', substr($line, $pos + 1));
+                    foreach ($tags as $tag) {
+                        $tagid = $this->ensure_tag($tag);
+                        $this->register_parts_tag($partsid, $tagid);
+                    }
+                    
+                } else if (strlen($line) > 0) {
+                    $tags = explode(',', $line);
+                    foreach ($tags as $tag) {
+                        $tagid = $this->ensure_tag($line); // 全体タグ
+                        $this->register_zip_tag($zipid, $tagid);
+                    }
+                }
+            }
+            $this->purge_unused_tags();
+            $this->pdo->commit();
+            $this->pdo = null;
+
+        } catch (Exception $e) {
+            $this->pdo->rollBack();
+            $this->pdo = null;
+            throw $e;
+        }
+    }
+}
+
+/***
+ * ZIPファイルからカタログファイルを抽出し、カタログを解析してデータベースを更新する。
+ * @param $zipfile ZIPファイル
+ * @param $zipid ZIPエントリのID
+ * @param $hostaddr タグ登録時のホストアドレス
+ */
+function search_zip_catalog($zipfile, $zipid, $hostaddr) {
+    $za = new ZipArchive();
+    $res = $za->open($zipfile);
+    if ($res === TRUE) {
+        for ($i = 0; $i < $za->numFiles; $i++) { 
+            $stat = $za->statIndex($i); 
+            $entryname = $stat['name'];
+            $entryname = str_replace('\\', '/', $entryname); // バックスラッシュで入っているzipもあり
+            if (!preg_match('/\/$/', $entryname)) {
+                // ファイル名とサイズの取り出し
+                $lcname = strtolower(pathinfo($entryname, PATHINFO_BASENAME));
+                $fsize = $stat['size'];
+                if ($lcname == 'catalog.txt' && $fsize > 0) {
+                    $fp = $za->getStream($entryname);
+                    if ($fp !== false) {
+                        // カタログファイルの読み込み(SJIS)
+                        $lines = [];
+                        while (($line = fgets($fp)) !== false) {
+                            $lines[] = mb_convert_encoding($line, 'UTF-8', 'SJIS');
+                        }
+                        // カタログの解析とデータベースへの登録
+                        $pc = new parse_catalog();
+                        $pc->hostaddr = $hostaddr;
+                        $pc->register($lines, $zipid);
+                    }
+                    fclose($fp);
+                    break;
+                }
+            }
+        }
+        $za->close();
+    }
+}
+?>
diff --git a/cgi-bin/report.php b/cgi-bin/report.php
new file mode 100644 (file)
index 0000000..62ec44c
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+////////////////////////////////////////
+// 通報を受け付けレポートとして登録するCGI
+////////////////////////////////////////
+require_once __DIR__ . '/common.php';
+
+if (!isset($_POST['selected_id']) || !isset($_POST['report']) || !isset($_POST['report_type'])) {
+    $result = [
+        'result' => 'error',
+        'zipid' => '',
+        'message' => 'Bad request'
+    ];
+    echo json_encode($result);
+    exit;
+}
+
+// 対象ZIP
+$zipid = $_POST['selected_id'];
+
+// 通報内容
+$report = $_POST['report'];
+
+// 通報タイプ
+$report_type = $_POST['report_type'];
+
+// 通報者のリモートアドレス
+$hostaddr = get_hostaddr();
+
+try {
+    $pdo = create_pdo();
+    $stm = $pdo->prepare('insert or replace into REPORT_ENTRIES(zipid, remote_addr, report_type, comment)
+        values(:zipid, :remote_addr, :report_type, :comment)');
+    $stm->execute([
+        'zipid' => $zipid,
+        'remote_addr' => $hostaddr,
+        'report_type' => $report_type,
+        'comment' => $report
+    ]);
+    
+    $result = [
+        'result' => 'ok',
+        'zipid' => $zipid,
+        'report_type' => $report_type
+    ];
+
+} catch (Exception $ex) {
+    $result = [
+        'result' => 'error',
+        'zipid' => $zipid,
+        'message' => 'internal error' // $ex->getMessage()
+    ];
+}
+echo json_encode($result);
+?>
\ No newline at end of file
diff --git a/cgi-bin/taglist.php b/cgi-bin/taglist.php
new file mode 100644 (file)
index 0000000..0f9b685
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+////////////////////////////////////
+// すべてのタグ一覧(JSON)を取得するCGI
+////////////////////////////////////
+require_once __DIR__ . '/common.php';
+require_once __DIR__ . '/calc_trip.php';
+
+header("Content-type: application/json; charset=utf-8");
+
+// DataGridのページャのページサイズとページ範囲、ソートカラム、順序等の情報を取得する
+$page = intval(isset($_GET['page']) ? $_GET['page'] : 1);
+$pageSize = intval(isset($_GET['rows']) ? $_GET['rows'] : 10);
+$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'tagname';
+$sortOrder = isset($_GET['order']) ? ($_GET['order'] == 'asc' ? 'asc' : 'desc') : 'asc';
+
+// パラメータの妥当性チェック
+$valid_columns = [
+    'id', 'tagname', 'maxcnt'
+];
+if (!in_array($sortColumn, $valid_columns)) {
+    // 妥当なカラム名でなければサーバーエラーを返す
+    header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
+    exit;
+}
+
+$pdo = create_pdo();
+
+// 件数取得
+$countFetch = $pdo->prepare('select count(*) from tag_entries');
+$countFetch->execute();
+$totalCount = $countFetch->fetchColumn();
+$countFetch = null;
+
+// ページャの要求範囲でのデータ取得(ソートも指定)
+$startPos = ($page - 1) * $pageSize;
+$fetch = $pdo->prepare("select id, tagname, maxcnt from tag_entries a left outer join (
+    select tagid, max(cnt) maxcnt from (
+    select tagid, count(*) cnt from tag_parts_rel group by tagid
+    union all
+    select tagid, count(*) cnt from tag_zip_rel
+    inner join parts_entries on tag_zip_rel.zipid = parts_entries.zipid group by tagid)
+    group by tagid) b on a.id = b.tagid
+    order by ${sortColumn} ${sortOrder} limit :pageSize offset :startPos");
+$fetch->execute([
+    //'sortArg' => ($sortColumn . ' ' . $sortOrder),
+    'pageSize' => $pageSize,
+    'startPos' => $startPos
+]);
+$rows = $fetch->fetchAll();
+if ($rows === false) {
+    // 読み取りに失敗した場合はサーバーエラーとする
+    header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
+    exit;
+}
+
+$fetch = null;
+$pdo = null;
+
+$result = [
+    'total' => $totalCount,
+    'rows' => $rows
+];
+echo json_encode($result);
+?>
\ No newline at end of file
diff --git a/cgi-bin/upload.php b/cgi-bin/upload.php
new file mode 100644 (file)
index 0000000..7790ea7
--- /dev/null
@@ -0,0 +1,244 @@
+<?php
+////////////////////////////////////
+// zipファイルのアップロードのCGI
+////////////////////////////////////
+
+ini_set('display_errors', 1);
+require_once __DIR__ . '/common.php';
+require_once __DIR__ . '/access_ctrl.php';
+require_once __DIR__ . '/calc_trip.php';
+require_once __DIR__ . '/read_catalog.php';
+
+header("Content-type: application/json; charset=utf-8");
+
+// パラメータの取り出し
+
+$title = isset($_POST['title']) ? $_POST['title'] : '';
+$author = isset($_POST['author']) ? $_POST['author'] : '';
+$delkey = isset($_POST['delkey']) ? $_POST['delkey'] : '';
+$license = isset($_POST['license']) ? $_POST['license'] : '';
+$comment = isset($_POST['comment']) ? $_POST['comment'] : '';
+$validation = isset($_POST['validation']) ? ($_POST['validation'] == 'true') : false;
+
+$result = ['result' => 'error'];
+
+// フォームの入力データが大きすぎればエラーとして何もしない。
+if (strlen($title) > 5000 || strlen($author) > 500 ||
+    strlen($delkey) > 500 || strlen($license) > 500 ||
+    strlen($comment) > 20000) {
+    echo json_encode($result);
+    exit;
+}
+
+$hostaddr = get_hostaddr();
+
+// 一時ファイルができているか(アップロードされているか)チェック
+if (isset($_FILES['file'])) {
+    $file = $_FILES['file'];
+    if ($file['error'] == 0) {
+        $ext = pathinfo($file['name'], PATHINFO_EXTENSION);
+        $fsize = $file['size'];
+        if (($ext != 'zip' && $ext != 'cmj') || $fsize < 1000) {
+            // 拡張子がzip, cmjでない、もしくは異常に小さい(1kb以下はありえない)
+            $result = ['result' => 'error', 'message' => '不正なZIPファイルです。'];
+    
+        } else {
+            // zipの中身のチェック
+            $result = check_zip($file['tmp_name'], $validation);
+            if ($result['result'] == 'ok') {
+                // 一時ファイルから所定フォルダへの退避
+                $fname = sha1_file($file['tmp_name']) . '.' . $ext;
+                $destzip = ZIPDIR . $fname;
+                if (move_uploaded_file($file['tmp_name'], $destzip)) {
+                    // データベースへの登録
+                    $result = append_entry($fname, $fsize, $title, $author, $delkey, $license, $comment, $hostaddr);
+                    if ($result['result'] == 'ok') {
+                        // catalog.txtの解析とデータベースへの登録
+                        $zipid = $result['id'];
+                        search_zip_catalog($destzip, $zipid, $hostaddr);
+                    }
+                } else {
+                    $result = ['result' => 'error', 'message' => 'ファイルの保存に失敗しました。'];
+                }
+            }
+        }
+    } else {
+        $result = ['result' => 'error', 'message' => 'ZIPファイルのアップロードでエラーが発生しました。err=' . $file['error']];
+    }
+} else {
+    $result = ['result' => 'error', 'message' => 'ZIPファイルがアップロードされていません。'];
+}
+
+echo json_encode($result);
+
+/**
+ * ZIPの中身を検証する
+ * 
+ * zipファイルが正しく、png, jpeg, txt以外を含んでいないこと
+ * 各サイズが800kb以下のこと。
+ * 
+ * @param string $zipfile ZIPファイルのパス
+ * @return array 結果を格納したarray
+ */
+function check_zip($zipfile, $validation) {
+    $error_files = [];
+    $large_files = [];
+
+    $za = new ZipArchive();
+    $res = $za->open($zipfile);
+    if ($res === TRUE) {
+        $filecnt = ['png' => 0, 'txt' => 0, 'jpeg' => 0, 'jpg' => 0,
+            'ini' => 0, 'xml' => 0, 'gif' => 0, 'root_png' => 0];
+        $hasCatalog = false;
+        $hasReadme = false;
+        for ($i = 0; $i < $za->numFiles; $i++) { 
+            $stat = $za->statIndex($i); 
+            $entryname = $stat['name'];
+            $entryname = str_replace('\\', '/', $entryname); // バックスラッシュで入っているzipもあり
+            if (!preg_match('/\/$/', $entryname)) {
+                // フォルダ以外なら、ファイル名と拡張子、サイズを取り出す
+                $dirname = pathinfo($entryname, PATHINFO_DIRNAME);
+                $fname = pathinfo($entryname, PATHINFO_FILENAME);
+                $ext = strtolower(pathinfo($entryname, PATHINFO_EXTENSION));
+                $lcname = strtolower(pathinfo($entryname, PATHINFO_BASENAME));
+                $fsize = $stat['size'];
+                if ($fname != 'license' && $fname != 'readme' && $fname != 'read me' &&
+                    $lcname != 'thumbs.db' && $lcname != '.ds_store' &&
+                    $ext != 'txt' && $ext != 'ini' && $ext != 'xml' && $ext != 'cpd' &&
+                    $ext != 'png' && $ext != 'jpg' && $ext != 'jpeg' && $ext != 'gif') {
+                    // 対象外のファイル拡張子、またはファイル名
+                    $error_files[] = $entryname;
+                } else {
+                    // ファイル数のカウント
+                    if ($ext == 'png' && $dirname == '.') {
+                        @$filecnt['root_png']++; // 親ディレクトリ上にあるPNGの数(パーツではない)
+                    } else {
+                        @$filecnt[$ext]++; // @で初回undefuned警告を握りつぶす
+                    }
+
+                    // カタログファイルか?
+                    if ($lcname == 'catalog.txt') {
+                        $hasCatalog = true;
+                    }
+                    // readmeか?
+                    if ($fname == 'readme' || $fname == 'read me') {
+                        $hasReadme = true;
+                    }
+
+                    // ファイルサイズが大きすぎる
+                    if (!(($ext == 'png' && $fsize <= MAX_PNG_SIZE) || 
+                          ($ext != 'xml' && $fsize <= MAX_CONTENT_SIZE))) {
+                        // pngならMAX_PNG_SIZE以下、xml以外ならMAX_CONTENT_SIZE以下のこと。
+                        // そうでなければ大きすぎる.
+                        $large_files[] = $entryname;
+                    }
+
+                    // validationの場合は、隠しファイル, gifもエラーにする
+                    if ($validation) {
+                        if ($lcname == 'thumbs.db' || $lcname == '.ds_store') {
+                            $error_files[] = $entryname;
+                        }
+                        if ($ext == 'gif') {
+                            $error_files[] = $entryname;
+                        }
+                    }
+                }
+            }
+        }
+        $za->close();
+        if ($validation && (!$hasCatalog || !$hasReadme)) {
+            return ['result' => 'error',
+                'message' => 'ZIPファイル内にreadme.txt、またはcatalog.txtが含まれていません。',
+                'error_files' => $error_files, 'large_files' => $large_files];
+        } else if (count($error_files) > 0 || count($large_files) > 0) {
+            return ['result' => 'error',
+                'message' => 'ZIPファイル内に許可されない、もしくは大きすぎるファイルが含まれています。',
+                'error_files' => $error_files, 'large_files' => $large_files];
+        } else if ($filecnt['png'] == 0) {
+            return ['result' => 'error',
+                'message' => 'ZIPファイル内に有効なパーツ画像がありません'];
+        } else if (($filecnt['root_png'] + $filecnt['jpg'] + $filecnt['jpeg'] +
+             $filecnt['gif'] + $filecnt['txt'] + $filecnt['xml']) > 5) {
+            return ['result' => 'error',
+                'message' => 'ZIPファイル内にパーツ画像以外のファイルが多数あります。'];
+        }
+        return ['result' => 'ok', 'message' => 'valid zip'];
+    }
+    return ['result' => 'error', 'message' => 'ZIPファイルが不正です', 'error_code' => $res];
+}
+
+/**
+ * データベースにエントリを追加または更新する
+ * 
+ * @param string $fname ファイル名
+ * @param integer $fsize ファイルサイズ
+ * @param string $title タイトル
+ * @param string $author 作者名
+ * @param string $delkey 削除キー
+ * @param integer $license ライセンスタイプ
+ * @param string $fncommentame コメント
+ * @param string $hostaddr リモートアドレス
+ */
+function append_entry($fname, $fsize, $title, $author, $delkey, $license, $comment, $hostaddr) {
+    $pdo = create_pdo();
+    
+    // アップロード回数制限中か?
+    $accessCtrl = new AccessCtrl($pdo);
+    if (!$accessCtrl->register($hostaddr) || $accessCtrl->getFailCount($hostaddr) > MAX_FAIL_COUNT) {
+        return [
+            'result' => 'error',
+            'message' => 'アップロード制限中です。しばらくお待ちください。'
+        ];
+    }
+
+    // エントリの確認
+    $fetch = $pdo->prepare('SELECT id, author, delkey FROM ZIP_ENTRIES WHERE fname = ?');
+    $fetch->execute([$fname]);
+    $row = $fetch->fetch();
+    $fetch = null;
+
+    if ($row === FALSE) {
+        // 新規挿入
+        $stmt = $pdo->prepare('INSERT INTO ZIP_ENTRIES(fname, fsize, title, author, delkey,
+            license, comment, hostaddr)
+            VALUES (:fname, :fsize, :title, :author, :delkey, :license, :comment, :hostaddr)');
+        $stmt->execute(['fname' => $fname, 'fsize' => $fsize, 'title' => $title, 'author' => $author,
+            'delkey' => $delkey, 'license' => $license, 'comment' => $comment,
+            'hostaddr' => $hostaddr]);
+        $id = $pdo->lastInsertId();
+        append_log("insert ${id} ${fname} ${fsize} ${author} ${delkey} ${title}");
+        $stmt = null;
+
+    } else {
+        // 更新
+        $id = $row['id'];
+        $prev_author = $row['author'];
+        $prev_delkey = $row['delkey'];
+
+        $passphrase = get_trip_pass($author);
+        $prev_passphrase = get_trip_pass($prev_author);
+        if (($prev_passphrase == '' && $delkey == $prev_delkey) ||
+            ($prev_passphrase != '' && $prev_passphrase == $passphrase)) {
+            // トリップ指定がない場合はdelkeyの一致、トリップの指定がある場合はトリップの一致をもって書き換え可とする
+            append_log("update ${id}");
+            $stmt = $pdo->prepare('UPDATE ZIP_ENTRIES
+                SET title = :title, author = :author, delkey = :delkey,
+                    license = :license, comment = :comment, hostaddr = :hostaddr
+                WHERE id = :id');
+            $stmt->execute(['id' => $id, 'title' => $title, 'author' => $author,
+                'delkey' => $delkey, 'license' => $license, 'comment' => $comment,
+                'hostaddr' => $hostaddr]);
+            $stmt = null;
+        } else {
+            append_log("unmatch delkey. zipid:${id}");
+            $accessCtrl->failIncrement($hostaddr);
+            $accessCtrl = null;
+            $pdo = null;
+            return ['result' => 'error', 'message' => 'トリップまたはdelkeyが一致しません'];
+        }
+    }
+    $accessCtrl = null;
+    $pdo = null;
+    return ['result' => 'ok', 'id' => $id, 'hostaddr' => $hostaddr];
+}
+?>
\ No newline at end of file
diff --git a/cgi-bin/zipinfo.php b/cgi-bin/zipinfo.php
new file mode 100644 (file)
index 0000000..30abf79
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+////////////////////////////////////////////////////////
+// 指定したzipファイルのコメント・タグ・パーツ一覧を取得するCGI
+////////////////////////////////////////////////////////
+require_once __DIR__ . '/common.php';
+require_once __DIR__ . '/calc_trip.php';
+
+header("Content-type: application/json; charset=utf-8");
+
+// 対象zipid
+$zipid = intval(isset($_GET['zipid']) ? $_GET['zipid'] : 1);
+// 取得するタイプ comment, tag, parts、組み合わせ可
+$contentsTypes = isset($_GET['contentsTypes']) ? $_GET['contentsTypes'] : [];
+
+header('x-contentTypes: ' . urlencode(var_export($contentsTypes, true)));
+
+$pdo = create_pdo();
+
+$stm = $pdo->prepare('select comment from zip_entries where id = ?');
+$stm->execute([$zipid]);
+$comment = $stm->fetchColumn();
+if ($comment == false) {
+    $comment = '';
+}
+
+$tags = [];
+if (in_array('tag', $contentsTypes, true)) {
+    $stm = $pdo->prepare('select distinct tagname, kind from (
+        select tagname, 0 kind from tag_entries inner join tag_zip_rel on tag_entries.id = tag_zip_rel.tagid where zipid = :zipid
+        union all
+        select tagname, 1 kind from tag_entries inner join tag_parts_rel on tag_entries.id = tag_parts_rel.tagid
+         inner join parts_entries on tag_parts_rel.partsid = parts_entries.id where zipid = :zipid
+        ) order by kind, tagname');
+    $stm->execute([
+        'zipid' => $zipid
+    ]);
+    $rows = $stm->fetchAll();
+    if ($rows !== false) {
+        foreach ($rows as $row) {
+            $tags[] = $row['tagname'];
+        }
+    }
+}
+
+$parts = [];
+if (in_array('parts', $contentsTypes, true)) {
+    $stm = $pdo->prepare('select partsname from parts_entries where zipid = ? order by partsname');
+    $stm->execute([$zipid]);
+    $rows = $stm->fetchAll();
+    if ($rows !== false) {
+        foreach ($rows as $row) {
+            $parts[] = $row['partsname'];
+        }
+    }
+}
+
+$result = [
+    'comment' => $comment,
+    'tags' => $tags,
+    'partsnames' => $parts
+];
+
+echo json_encode($result);
+?>
diff --git a/cgi-bin/ziplist.php b/cgi-bin/ziplist.php
new file mode 100644 (file)
index 0000000..7219e2e
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+////////////////////////////////////////////////////////
+// zipファイル一覧JSONを取得するCGI
+////////////////////////////////////////////////////////
+require_once __DIR__ . '/common.php';
+require_once __DIR__ . '/calc_trip.php';
+
+header("Content-type: application/json; charset=utf-8");
+
+// DataGridのページャのページサイズとページ範囲、ソートカラム、順序等の情報を取得する
+$page = intval(isset($_GET['page']) ? $_GET['page'] : 1);
+$pageSize = intval(isset($_GET['rows']) ? $_GET['rows'] : 10);
+$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'id';
+$sortOrder = isset($_GET['order']) ? ($_GET['order'] == 'asc' ? 'asc' : 'desc') : 'asc';
+$tzoffset = intval(isset($_GET['tzoffset']) ? $_GET['tzoffset'] : 0);
+
+// パラメータの妥当性チェック
+$valid_columns = [
+    'id', 'fname', 'title', 'href', 'author', 'comment', 'regdate', 'fsize', 'license', 'report_cnt'
+];
+if (!in_array($sortColumn, $valid_columns, true)) {
+    // 妥当なカラム名でなければサーバーエラーを返す
+    header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
+    exit;
+}
+
+$pdo = create_pdo();
+
+// 件数取得
+$countFetch = $pdo->prepare('select count(*) from ZIP_ENTRIES');
+$countFetch->execute();
+$totalCount = $countFetch->fetchColumn();
+$countFetch = null;
+
+// ページャの要求範囲でのデータ取得(ソートも指定)
+$startPos = ($page - 1) * $pageSize;
+$fetch = $pdo->prepare("select id, title, fname, fsize, href, author,
+ datetime(regdate, :tzoffsetArg) regdate, license, hostaddr, downloaded, B.report_cnt from ZIP_ENTRIES
+ left outer join (select zipid, count(*) report_cnt from REPORT_ENTRIES group by zipid) B
+ on ZIP_ENTRIES.id = B.zipid
+ where not exists (select * from REPORT_ENTRIES C where C.zipid = ZIP_ENTRIES.id and C.remote_addr = :hostaddr)
+ order by ${sortColumn} ${sortOrder} limit :pageSize offset :startPos");
+$fetch->execute([
+    //'sortArg' => ($sortColumn . ' ' . $sortOrder), // sqlite3の不具合によりバインド変数が機能しないバージョンあり
+    'tzoffsetArg' => ($tzoffset . ' minutes'),
+    'pageSize' => $pageSize,
+    'startPos' => $startPos,
+    'hostaddr' => get_hostaddr()
+]);
+$rows = $fetch->fetchAll();
+if ($rows === false) {
+    // 読み取りに失敗した場合はサーバーエラーとする
+    header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
+    exit;
+}
+
+foreach ($rows as &$row) {
+    // DB上のhostaddrを表示用にハッシュ化する
+    $hostaddr = $row['hostaddr'];
+    $row['hostaddr'] = substr(base64_encode(sha1(HOSTADDR_SALT . $hostaddr)), 0, 8);
+    // DB上のAuthorを表示用にトリップ変換したものにする
+    $author = $row['author'];
+    $row['author'] = get_display_author_name($author);
+}
+unset($row);
+
+$fetch = null;
+$pdo = null;
+
+$result = [
+    'total' => $totalCount,
+    'rows' => $rows
+];
+echo json_encode($result);
+?>
\ No newline at end of file
diff --git a/dbupdate.sh b/dbupdate.sh
new file mode 100755 (executable)
index 0000000..d102900
--- /dev/null
@@ -0,0 +1,8 @@
+#!/bin/bash
+DBFILE=cgi-bin/mydb.db
+(printf ".open ${DBFILE}\n"
+echo 'ls -1 zip' | sftp -q seraphy@storage.osdn.net:/storage/groups/c/ch/charactermanaj | grep -v "^sftp>" | while read line
+do
+   printf "update zip_entries set href='https://osdn.net/projects/charactermanaj/storage/${line}' where fname='${line#zip/}';\n" 
+done;
+) | sqlite3
diff --git a/htdocs/oauth_redirect.php b/htdocs/oauth_redirect.php
new file mode 100644 (file)
index 0000000..679532f
--- /dev/null
@@ -0,0 +1,153 @@
+<?php
+////////////////////////////////////////
+// OAuth2によるログイン処理のためのCGI
+////////////////////////////////////////
+//ini_set('display_errors', 1);
+require_once __DIR__ . '/../cgi-bin/common.php';
+require_once __DIR__ . '/../cgi-bin/secret.php';
+
+if (empty($_GET['state'])) {
+    // 承諾の可否にかかわらず、stateは返されるはず。そうでなければ何らかの内部エラー
+    header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
+    exit;
+}
+$state = $_GET['state'];
+if ($state == 'localhost' || $state == 'localhost.charactermanaj.osdn.jp') {
+    // 開発用ローカルの識別子
+    $redirect_url = "http://${state}:8888/htdocs/upload.html";
+} else if ($state == 'native') {
+    // ネイティブの識別子
+    $redirect_url = "";
+} else {
+    // そうでなければ公開用と見なす
+    $redirect_url = 'http://charactermanaj.osdn.jp/upload.html';
+}
+
+if (empty($_GET['code'])) {
+    // 承諾しなかった場合はcode等は設定されずにリダイレクトされる
+    if (empty($redirect_url)) {
+        header($_SERVER['SERVER_PROTOCOL'] . ' 401 ACCESS DENIED', true, 401);
+    } else {
+        header('location: ' . $redirect_url);
+    }
+    exit;
+}
+$code = $_GET['code'];
+
+$curl = curl_init();
+if ($curl === false) {
+    header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
+    exit;
+}
+
+curl_setopt($curl, CURLOPT_URL, 'https://osdn.net/api/v0/token');
+curl_setopt($curl, CURLOPT_HTTPHEADER, [
+    'User-Agent: Wget/1.16 (linux-gnu)'
+]);
+
+// リダイレクトの有効化、回数制限、リファラの自動設定
+curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
+curl_setopt($curl, CURLOPT_MAXREDIRS, 10);
+curl_setopt($curl, CURLOPT_AUTOREFERER, true);
+
+// SSL対応
+curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
+
+$params = [
+    'client_id' => OSDN_CMJ_CLIENTKEY,
+    'client_secret' => OSDN_CMJ_SECRETKEY,
+    'grant_type' => 'authorization_code',
+    'code' => $code
+];
+curl_setopt($curl, CURLOPT_POST, true);
+curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($params));
+
+curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
+$res = curl_exec($curl);
+if ($res === false ) {
+    header("Content-type: text/plain; charset=utf-8");
+    echo '{"error":"connection error","error_description":"connection error"}';
+    exit;
+}
+
+$result = json_decode($res, true);
+if (isset($result['error'])) {
+    header("Content-type: text/plain; charset=utf-8");
+    echo $res;
+    exit;
+}
+
+$access_token = $result['access_token'];
+
+//curl -X GET --header "Accept: application/json" --header "Authorization: Bearer baa4d1727b231209547058555c4c662ac7dca68f" "https://osdn.net/api/v0/ping"
+
+curl_setopt($curl, CURLOPT_URL, 'https://osdn.net/api/v0/ping');
+curl_setopt($curl, CURLOPT_POST, false);
+
+curl_setopt($curl, CURLOPT_HTTPHEADER, [
+    'User-Agent: Wget/1.16 (linux-gnu)',
+    'Authorization: Bearer ' . $access_token
+]);
+
+curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
+$res = curl_exec($curl);
+if ($res === false ) {
+    header("Content-type: text/plain; charset=utf-8");
+    echo '{"error":"connection error","error_description":"connection error"}';
+    exit;
+}
+
+$result = json_decode($res, true);
+if (isset($result['error'])) {
+    header("Content-type: text/plain; charset=utf-8");
+    echo $res;
+    exit;
+}
+
+$login_id = $result['login_id'];
+$login_name = $result['login_name'];
+
+if ($state == 'json') {
+    header("Content-type: text/plain; charset=utf-8");
+    echo $res;
+    exit;    
+}
+
+$credential = [
+    'id' => $login_id,
+    'expired' => time() + (24 * 60 * 60)
+];
+$credential_json = json_encode($credential);
+$credential_json_b64 = base64url_encode($credential_json);
+$credential_hmac_b64 = base64url_encode(hash_hmac('sha256', $credential_json, CMD_UPLOADER_SID_KEY, true));
+$sid = $credential_json_b64 . '.' . $credential_hmac_b64;
+
+setcookie('cmj-uploader-sid', $sid, 0, '/', '.charactermanaj.osdn.jp');
+
+append_log("login: ${login_id} ${login_name} ${sid}");
+
+$pdo = create_pdo();
+
+// アップロード・削除制限レコードをクリアする
+$stm = $pdo->prepare('delete from REMOTE_ENTRIES where REMOTE_ADDR = ?');
+$stm->execute([
+    get_hostaddr()
+]);
+
+?><!doctype html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <title>login</title>
+    <?php if (!empty($redirect_url)): ?>
+        <meta http-equiv="refresh" content="0;URL='<?= $redirect_url ?>'"/>
+    <?php endif; ?>
+</head>
+<body>
+    <?php if (!empty($redirect_url)): ?>
+        <a href="<?= $redirect_url ?>">logged in</a>
+    <?php else: ?>
+        <p>LOGIN CODE: <?= $sid ?>
+    <?php endif; ?>
+</body>
+</html>
\ No newline at end of file
diff --git a/htdocs/upload.css b/htdocs/upload.css
new file mode 100644 (file)
index 0000000..2f9fe84
--- /dev/null
@@ -0,0 +1,30 @@
+html {
+    height: 100%;
+}
+
+body {
+    height: 100%;
+    margin: 0;
+    font-size: 10pt;
+}
+
+h1 {
+    font-size: 14pt;
+    text-shadow: 1px 1px 2px #1c8ae4;
+    border: thin solid #1c8ae4;
+    box-shadow: 2px 2px 4px lightblue;
+}
+
+h2 {
+    font-size: 12pt;
+    text-shadow:1px 1px 2px #1c8ae4;
+    text-decoration: underline;
+}
+
+table.report tr td {
+    vertical-align: top;
+}
+
+pre.comment_area {
+    margin: 1em;
+}
\ No newline at end of file
diff --git a/htdocs/upload.html b/htdocs/upload.html
new file mode 100644 (file)
index 0000000..3725142
--- /dev/null
@@ -0,0 +1,299 @@
+<!doctype html>
+<html lang="ja">
+
+<head>
+    <meta charset="UTF-8">
+    <title>キャラクターなんとか機・互換機用 パーツ保管庫</title>
+    <link rel="stylesheet" type="text/css" href="//www.jeasyui.com/easyui/themes/default/easyui.css">
+    <link rel="stylesheet" type="text/css" href="//www.jeasyui.com/easyui/themes/icon.css">
+    <link rel="stylesheet" type="text/css" href="//www.jeasyui.com/easyui/themes/color.css">
+    <link rel="stylesheet" type="text/css" href="upload.css">
+    <script type="text/javascript" src="//code.jquery.com/jquery-1.9.1.min.js"></script>
+    <script type="text/javascript" src="//www.jeasyui.com/easyui/jquery.easyui.min.js"></script>
+    <script type="text/javascript" src="upload.js"></script>
+</head>
+
+<body>
+    <div class="easyui-layout" style="width:100%;height:100%;">
+        <div data-options="region:'north',split:true" title="説明" style="height:300px;">
+            <div style="margin: 5px;">
+                <h1>キャラクターなんとか機/キャラクターなんとかJ互換機用 パーツ保管庫</h1>
+                <p>キャラクターなんとか機、キャラクターなんとかJ、または、その互換機のパーツデータの公開・配布にご自由にご利用ください。</p>
+                <p>※ それ以外の利用は禁止いたします。</p>
+                <h2>アップロード方法</h2>
+                <ul>
+                    <li>
+                        zip形式で圧縮されたパーツデータをアップロードすることができます。
+                        <ul>
+                            <li>最大4M Bytesまでに制限されています。</li>
+                            <li>暗号化には対応していません。</li>
+                            <li>zipファイルには以下のファイルのみ格納できます。(それ以外がある場合はエラーとなります)
+                                <ul>
+                                    <li>readme、ライセンスなどテキストファイル</li>
+                                    <li>catalog.txt カタログファイル</li>
+                                    <li>キャラクターなんとか機のini、キャラクターなんとかJのxml、cpdファイル</li>
+                                    <li>パーツのPNG画像</li>
+                                    <li>preview.jpeg, sample.jpgなどのJPEGのサンプル画像(個数制限あり)</li>
+                                </ul>
+                            </li>
+                            <li>Macの場合は.DS_Storeフォルダ、Windowsの場合は Thumbs.db や _thumbnl.sueなどの隠しファイルが無いことを確認してください。</li>
+                            <li>画像ファイルも大きすぎるもの、多すぎるものは不正なファイルとしてアップロードできません。</li>
+                            <li>
+                                「ファイルの内容をチェックする」にレ点を入れている場合は、zipの中身に隠しファイルが残っている場合や、
+                                <b>readme.txt</b>または<b>catalog.txt</b>が含まれていない場合はアップロードしないようにします。</li>
+                        </ul>
+                    </li>
+                    <li>
+                        ライセンスの概要を選択してください。
+                        <ul>
+                            <li>オープンソースの場でのアップローダーとなりますので、ダウンロードした利用者が自由に使えないライセンスでは、ここではアップロードできません。</li>
+                            <li>個人利用は自由だが商用の場合は要相談といった複数ライセンス形態は問題ありません。</li>
+                            <li>zip中にも必ずライセンスを記述したテキストファイルを格納してください。</li>
+                        </ul>
+                    </li>
+                    <li>
+                        zipファイル中には、かならず連絡先を記述してください。(url、メールアドレスなど)
+                    </li>
+                    <li>
+                            アップロードは(成否問わず)1時間に5回までに制限しています。一度にたくさんのアップロードはできません。また、削除は5回失敗すると、しばらく(1時間ほど)使えなくなります。
+                            <ul>
+                                <li>「アップロード制限の解除」ボタンからOSDNユーザーとしてログインした場合は、この制限は緩和されます。</li>
+                            </ul>
+                    </li>
+                    <li>アップロードされた画像やテキストファイルなどzipの中身の著作権は著作者様にありますが、アップロードされたファイルの公開可否の判断や削除等は管理者が予告なく行えるものとします。</li>
+                    <li>アップロードされたzipファイルについては、管理の都合上、管理者もしくはプログラムによってzipを固め直したり、不要ファイルの削除等の変更、アップロードし直すことがあることをご了承ください。</li>
+                </ul>
+
+                <h3>ZIPファイル名について</h3>
+                <p>zipファイル名は「タイトル@作者名$削除キー.zip」のように@と$で区切っておくと、アップロードファイルを選択すると自動的に作者名と削除キーを設定します。</p>
+                <p>この命名規則に従わなくてもアップロードできますが、このようにしておくと手元に残る備忘録になります。</p>
+                <p><example>例: 「パーツxxx@seraphy$0000.zip」とか、「パーツyyyy@seraphy.zip」のようなファイル名とする。</example></p>
+
+                <h3>カタログファイルについて</h3>
+                <p>
+                    <code>catalog.txt</code>というファイルをzipのルートフォルダにおいておくと、アップローダはパーツ一覧やタグ一覧として認識します。</p>
+                <p>テキスト形式で<b>SJIS</b>で記述しておく必要があります。</p>
+                <p>カタログは</p>
+                <example>
+                    <code>
+                    &nbsp;&nbsp;ZIPファイル全体のタグ名,...<br>
+                    &nbsp;&nbsp;パーツ名<b>=</b>パーツのタグ名,....<br>
+                    </code>
+                </example>
+                <p>のように、パーツ名の後ろに
+                    <b>=</b> をつけてパーツごとのタグを指定できます。タグはカンマ区切りで複数指定できます。</p>
+                <p>イコールで区切らない場合はZIPファイル全体のタグ名として認識されます。</p>
+                <p>パーツごとのタグとしては、個別の画像サイズ、パーツのカテゴリ(body, hair...)などを指定します。</p>
+                <p>ZIPファイル全体のタグとしては、対象とするキャラクターセットの種類などを指定します。</p>
+                <p>(パーツサイズがすべて同一であれば、パーツごとにサイズを記述する必要なく、ZIP全体のタグとしてサイズを指定できます。)</p>
+
+                <h2>削除・更新方法</h2>
+                <p>アップロード時に任意の削除キーを指定できます。アップロード後に削除または更新するには、 削除キーか、作者名につけたトリップのいずれかが一致した場合のみ削除できます。 (削除キー、トリップともに、いたずら防止程度のものです。)<br>
+                    削除キーは4文字以上が推奨です。(最大30文字)
+                </p>
+                <p>(コメント等を更新したい場合は同一内容のzipファイルを再アップロードしてください。)</p>
+                <h2>免責事項・注意点・技術情報</h2>
+                <p>
+                    <b>
+                        <font color="red">本サービスはまったくの無保証です。データの保全等、一切の保証はいたしません。</font>
+                    </b>
+                </p>
+                <p>また、
+                    <b>技術的な理由、あるいは不適切なデータ等など、
+                        <u>理由の如何に関わらず、予告なくデータを削除する</u>場合があります。</b>
+                </p>
+                <p>なお、アップロードされたファイルはOSDN.NETのウェブ領域を一時的に借りているだけなので、 もし容量が圧してきた場合は、 こちらでOSDN.NETのファイル領域にアップロードしなおします。(代行します)
+                </p>
+                <p>本サービスの画面のコンポーネントには、<a href="https://www.jeasyui.com">jQuery EasyUI (Freeware Edition)</a>を利用しています。</p>
+                <p>本サービスはOSDN.NETによりホストされています。</p>
+                <a href="https://osdn.net/">
+                    <img src="//osdn.net/sflogo.php?group_id=5265&amp;type=1" width="96" height="31" border="0" alt="OSDN">
+                </a>
+                <h2>ご質問・連絡先</h2>
+                <p>このサービスのご意見・ご質問がありましたら、
+                    <a href="//osdn.net/projects/charactermanaj/forums/">キャラクターなんとかJのフォーラム</a>か、メールにてseraphy にご連絡ください。</p>
+                <p>なお、アップロードされたデータの著作権は作者にあり、それについてseraphyまたはOSDNに問い合わせがあっても当事者でないため判断できません。</p>
+                <p>データについては
+                    <b>作者に直接お問い合わせください。</b>
+                </p>
+            </div>
+        </div>
+        <div data-options="region:'center'">
+            <div class="easyui-tabs" data-options="fit: true">
+                <div title="ZIPファイル一覧" iconCls="icon-save">
+                    <div class="easyui-layout" data-options="fit:true">
+                        <div data-options="region:'east',split:true" style="width:300px">
+                            <pre class="comment_area" id="zip_comment_area"></pre>
+                        </div>
+                        <div data-options="region:'center'">
+                            <table id="filetable" class="easyui-datagrid" data-options="rownumbers:true,border:false,singleSelect:true,fit:true,fitColumns:false">
+                                <thead>
+                                    <tr>
+                                        <th data-options="field:'ck',checkbox:true,sortable:true"></th>
+                                        <th data-options="field:'title',sortable:true,formatter:formatTitle" width="250">タイトル</th>
+                                        <th data-options="field:'author',sortable:true,formatter:escapeHTML" width="180">作者名</th>
+                                        <th data-options="field:'fsize',align:'right',sortable:true,formatter:commafy" width="100">サイズ</th>
+                                        <th data-options="field:'regdate',sortable:true,formatter:escapeHTML" width="180">登録日時</th>
+                                        <th data-options="field:'license',sortable:true,formatter:get_display_license" width="200">ライセンス</th>
+                                        <th data-options="field:'downloaded',sortable:true,formatter:commafy" width="100">DL数</th>
+                                        <th data-options="field:'report_cnt',sortable:true,formatter:commafy" width="80">通報数</th>
+                                        <th data-options="field:'hostaddr',sortable:true,formatter:escapeHTML" width="150">ホスト</th>
+                                    </tr>
+                                </thead>
+                            </table>
+                        </div>
+                    </div>
+                </div>
+                <div title="カタログ定義の検索" iconCls="icon-search" data-options="fit:true">
+                    <div class="easyui-layout" data-options="fit:true">
+                        <div data-options="region:'west',split:true" style="width:400px">
+                            <table id="taglist" class="easyui-datagrid" data-options="border:false,singleSelect:false,fit:true,fitColumns:false">
+                                <thead>
+                                    <tr>
+                                        <th data-options="field:'ck',checkbox:true,sortable:true"></th>
+                                        <th data-options="field:'tagname',sortable:true,formatter:escapeHTML" width="250">タグ名</th>
+                                        <th data-options="field:'maxcnt',align:'right',sortable:true,formatter:commafy" width="80">パーツ数</th>
+                                    </tr>
+                                </thead>
+                            </table>
+                        </div>
+                        <div data-options="region:'east',split:true" style="width:300px">
+                            <pre class="comment_area" id="parts_comment_area"></pre>
+                        </div>
+                        <div data-options="region:'center'">
+                            <table id="partslist" class="easyui-datagrid" data-options="border:false,singleSelect:true,fit:true,fitColumns:false">
+                                <thead>
+                                    <tr>
+                                        <th data-options="field:'partsname',sortable:true,formatter:escapeHTML" width="250">パーツ名</th>
+                                        <th data-options="field:'title',sortable:true,formatter:formatTitle" width="180">タイトル</th>
+                                        <th data-options="field:'fsize',align:'right',sortable:true,formatter:commafy" width="100">サイズ</th>
+                                        <th data-options="field:'author',align:'right',sortable:true,formatter:escapeHTML" width="180">作者</th>
+                                        <th data-options="field:'regdate',sortable:true,formatter:escapeHTML" width="180">登録日時</th>
+                                        <th data-options="field:'license',sortable:true,formatter:get_display_license" width="200">ライセンス</th>
+                                        <th data-options="field:'downloaded',align:'right',sortable:true,formatter:commafy" width="100">DL数</th>
+                                    </tr>
+                                </thead>
+                            </table>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div id="upDlg" class="easyui-dialog" title="アップロード" data-options="modal:true, iconCls:'icon-save', resizable: true, closed: true"
+        style="width:500px;height:400px;padding:10px">
+        <form enctype="multipart/form-data" onsubmit="return uploadForm(this)">
+            <table>
+                <tr>
+                    <td>ZIPファイル</td>
+                    <td>
+                        <input type="file" name="file" accept=".zip,.cmj" required>
+                    </td>
+                </tr>
+                <tr>
+                    <td>タイトル</td>
+                    <td>
+                        <input type="text" name="title" maxlength="100" required>
+                    </td>
+                </tr>
+                <tr>
+                    <td>作者名</td>
+                    <td>
+                        <input type="text" name="author" maxlength="100" placeholder="作者名#トリップキー" required>(#でトリップキー指定可)</td>
+                </tr>
+                <tr>
+                    <td>ライセンス</td>
+                    <td>
+                        <select name="license" required>
+                            <option selected value="0">商用可・改変可・再頒布可</option>
+                            <option value="1">商用可・改変不可・再頒布可</option>
+                            <option value="2">商用不可・改変可・再頒布可</option>
+                            <option value="3">商用不可・改変不可・再頒布可</option>
+                        </select>
+                        <br> ※ 詳細をzip内ドキュメントに必ず明示してください。
+                    </td>
+                </tr>
+                <tr>
+                    <td>コメント</td>
+                    <td>
+                        <textarea name="comment" cols="40" rows="8" maxlength="3000" placeholder="紹介文、注意点などをご記入ください。(3000文字以内)"></textarea>
+                    </td>
+                </tr>
+                <tr>
+                    <td>削除キー</td>
+                    <td>
+                        <input type="text" name="delkey" maxlength="30" required>(削除する場合に必要です)
+                    </td>
+                </tr>
+                <tr>
+                    <td></td>
+                    <td>
+                        <label><input type="checkbox" name="validation" checked value="true">ファイルの内容チェック</label>
+                    </td>
+                </tr>
+                <tr>
+                    <td></td>
+                    <td>
+                        <input type="submit" value="ファイルをアップロードする">
+                    </td>
+                </tr>
+            </table>
+        </form>
+    </div>
+    <div id="delDlg" class="easyui-dialog" title="削除" data-options="modal:true, iconCls:'icon-save', resizable: true, closed: true"
+        style="width:500px;height:200px;padding:10px">
+        <p>登録時に指定した削除キーを指定してください。作者名にトリップを指定していた場合はトリップでも削除できます。</p>
+        <form onsubmit="return deleteForm(this)">
+            <input type="hidden" name="selected_id">
+            <div>
+                <label style="display:inline-block;width: 100px;">
+                    <input type="radio" name="keytype" value="DELKEY" checked>削除キー</label>
+                <input type="text" name="verifydelkey">
+            </div>
+            <div>
+                <label style="display:inline-block;width: 100px;">
+                    <input type="radio" name="keytype" value="TRIP">トリップ</label>
+                <input type="text" name="verifytrip" disabled>
+            </div>
+            <input type="submit" value="削除する">
+        </form>
+    </div>
+    <div id="reportDlg" class="easyui-dialog" title="通報" data-options="modal:true, resizable: true, closed: true"
+        style="width:500px;height:450px;padding:10px">
+        <p>ZIPファイル: <b><span id="report_zip_title"></span></b></p>
+        <p>このzipファイルが不適切である理由をお知らせください。</p>
+        <form onsubmit="return reportForm(this)">
+            <input type="hidden" name="selected_id">
+            <table class="report">
+                <tr>
+                    <td>理由の種類</td>
+                    <td>
+                        <label><input type="radio" name="report_type" required value="0">ファイルが壊れている</label><br>
+                        <label><input type="radio" name="report_type" required value="1">パーツセットでない</label><br>
+                        <label><input type="radio" name="report_type" required value="2">公序良俗に反している。違法の疑いがある。</label><br>
+                        <label><input type="radio" name="report_type" required value="3">利用できないライセンス。権利侵害の疑いがある。</label><br>
+                        <label><input type="radio" name="report_type" required value="4">広告、スパムである</label><br>
+                        <label><input type="radio" name="report_type" required value="5">その他</label><br>
+                    </td>
+                </tr>
+                <tr>
+                    <td>報告内容</td>
+                    <td>
+                        <textarea name="report" required cols="40" rows="8" maxlength="1000" placeholder="問題点を具体的に正確に説明してください。(1000文字以内)"></textarea>
+                    </td>
+                </tr>
+                <tr>
+                    <td></td>
+                    <td>
+                        <input type="submit" value="通報します。取り消しはできません。">
+                    </td>
+                </tr>
+            </table>
+        </form>
+    </div>
+    <div id="showSidDlg" class="easyui-dialog" title="ログインキー" data-options="modal:true, resizable: true, closed: true"
+        style="width:400px;height:150px;padding:10px">
+        <textarea id="txtSid" cols="45" rows="5" readonly></textarea>
+    </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/htdocs/upload.js b/htdocs/upload.js
new file mode 100644 (file)
index 0000000..a08a369
--- /dev/null
@@ -0,0 +1,573 @@
+// 2018/6時点のIE11を想定しているのでES6のうちconst, letぐらいしか使わない
+
+const MAX_UPLOAD_SIZE = 4 * 1024 * 1024; // 4MB
+const MAX_COMMENT_SIZE = 3000; // 3000文字
+
+function escapeHTML(str) {
+    "use strict";
+    if (str) {
+        return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+    }
+    return '';
+}
+
+// https://ja.stackoverflow.com/questions/2582/
+function commafy(n) {
+    "use strict";
+    if (n) {
+        let parts = n.toString().split('.');
+        parts[0] = parts[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,');
+        return parts.join('.');
+    }
+    return '';
+}
+
+function get_display_license(lic) {
+    "use strict";
+    switch (lic) {
+        case '0': return '商用可・改変可・再頒布可';
+        case '1': return '商用可・改変不可・再頒布可';
+        case '2': return '商用不可・改変可・再頒布可';
+        case '3': return '商用不可・改変不可・再頒布可';
+    }
+    return lic;
+}
+
+function reportForm(form) {
+    "use strict";
+    try {
+        $.messager.confirm({
+            title: '通報の確認',
+            msg: 'このIPアドレスからは、ただちにファイルにアクセスできなくなります。取り消しはできません。',
+            fn: function (r) {
+                if (r) {
+                    let formData = new FormData(form);
+                    $.ajax({
+                        url: '/cgi-bin/report.php',
+                        data: formData,
+                        type: 'POST',
+                        dataType: 'json', // レスポンスをJSONとして解析
+                        processData: false, // Ajaxがdataを整形しない指定
+                        contentType: false // マルチパート送信のおまじない
+                    }).done(function (res) {
+                        if (res.result == 'ok') {
+                            $('#zip_comment_area').text('');
+                            $('#filetable').datagrid('reload');
+                            $('#taglist').datagrid('reload');
+                            $.messager.alert('完了', '通報しました', 'info', function () {
+                                form.reset();
+                                $('#reportDlg').dialog('close');
+                            });
+                        } else {
+                            $.messager.alert('通報に失敗', '通報できませんでした。\n' + res.message, 'error');
+                        }
+
+                    }).fail(function (jqXHR, textStatus, errorThrown) {
+                        alert('通報に失敗しました。:' + textStatus + ":" + errorThrown);
+                    });
+                }
+            }
+        });
+
+    } catch (e) {
+        alert(e);
+    }
+    return false;
+}
+
+function uploadForm(form) {
+    "use strict";
+    try {
+        let comment = form['comment'];
+        let title = form['title'];
+        if (comment.value.length > MAX_COMMENT_SIZE || title.value.length > MAX_COMMENT_SIZE) {
+            $.messager.alert('アップロードに失敗',
+                'コメントまたはタイトルが長すぎます。\nlimit ' + MAX_COMMENT_SIZE + 'bytes', 'error');
+            return false;
+        }
+
+        let author = form['author'].value;
+        let delkey = form['delkey'].value;
+        let license = form['license'].value;
+        if (author.length > 100 || delkey.length > 100) {
+            $.messager.alert('アップロードに失敗', '入力テキストが長すぎます', 'error');
+            return false;
+        }
+
+        // authorとdelkeyはブラウザのローカルストレージに記憶しておく
+        localStorage.setItem('uploadForm.author', author);
+        localStorage.setItem('uploadForm.delkey', delkey);
+        localStorage.setItem('uploadForm.license', license);
+
+        let file = form['file'];
+        if (file && file.files.length == 1) {
+            if (file.files[0].size > MAX_UPLOAD_SIZE) {
+                $.messager.alert('アップロードに失敗',
+                    'ファイルが大きすぎます。\nlimit ' + MAX_UPLOAD_SIZE + 'bytes', 'error');
+                return false;
+            }
+            let fname = file.files[0].name;
+            let pos = fname.lastIndexOf('.');
+            let ext = '';
+            if (pos > 0) {
+                ext = fname.substring(pos + 1).toLowerCase();
+            }
+            if (ext != 'zip' && ext != 'cmj') {
+                $.messager.alert('アップロードに失敗',
+                    'zipファイルのみアップロードできます。', 'error');
+                return false;
+            }
+
+            let formData = new FormData(form);
+            $.ajax({
+                url: '/cgi-bin/upload.php',
+                data: formData,
+                type: 'POST',
+                dataType: 'json', // レスポンスをJSONとして解析
+                processData: false, // Ajaxがdataを整形しない指定
+                contentType: false // マルチパート送信のおまじない
+            }).done(function (res) {
+                if (res.result == 'ok') {
+                    $('#filetable').datagrid('reload');
+                    $('#taglist').datagrid('reload');
+                    $.messager.alert('アップロード完了', 'アップロードしました', 'info', function () {
+                        form['file'].value = '';
+                        form['title'].value = '';
+                    });
+                } else {
+                    let msg = res.message; 
+                    if (res.error_files && res.error_files.length > 0) {
+                        msg += '<div><ul>';
+                        $.each(res.error_files, function (idx, err_file_name) {
+                            msg += '<li>' + escapeHTML(err_file_name) + '</li>';
+                        });
+                        msg += '</ul></div>';
+                    }
+                    $.messager.alert('アップロードに失敗', 'アップロードできません。<br>' + msg, 'error');
+                }
+
+            }).fail(function (jqXHR, textStatus, errorThrown) {
+                alert('アップロードに失敗しました。:' + textStatus + ":" + errorThrown);
+            });
+        }
+
+    } catch (e) {
+        alert(e);
+    }
+    return false;
+};
+
+function deleteForm(form) {
+    "use strict";
+    try {
+        let formData = new FormData(form);
+        $.ajax({
+            url: '/cgi-bin/delete.php',
+            data: formData,
+            type: 'POST',
+            dataType: 'json', // レスポンスをJSONとして解析
+            // Ajaxがdataを整形しない指定
+            processData: false,
+            // contentTypeもfalseに指定
+            contentType: false
+
+        }).done(function (res) {
+            if (res.result == 'ok') {
+                $('#filetable').datagrid('reload');
+                $('#taglist').datagrid('reload');
+                $.messager.alert('削除完了', '削除しました', 'info', function () {
+                    form.reset();
+                    $('#delDlg').dialog('close');
+                });
+            } else {
+                $.messager.alert('削除に失敗', '削除できません。\n' + res.message, 'error');
+            }
+
+        }).fail(function (jqXHR, textStatus, errorThrown) {
+            alert('削除に失敗しました。:' + textStatus + ":" + errorThrown);
+        });
+
+    } catch (e) {
+        alert(e);
+    }
+    return false;
+};
+
+function formatTitle(val, row, idx) {
+    "use strict";
+    return '<a href="#" class="easyui-tooltip" title="File ID: ' + row['id'] +
+        '" onclick="clickTitle(' + row['id'] + ',' + idx + ')">' + escapeHTML(val) + '</a>';
+}
+
+function clickTitle(id, idx) {
+    "use strict";
+    let data = $('#filetable').datagrid('getData');
+    if (!data || !data.rows) {
+        return false;
+    }
+    try {
+        let row = data.rows[idx];
+        let datafile = row['fname'];
+        let pos = datafile.lastIndexOf('.');
+        let ext = (pos > 0) ? datafile.substring(pos) : '.zip';
+        let filename = row['title'] + ext;
+
+        // https://qiita.com/tom_konda/items/484955b8332e0305ebc4
+        let xhr = new XMLHttpRequest();
+        xhr.open('GET', '/cgi-bin/download.php?id=' + id, true);
+        xhr.responseType = 'blob';
+        xhr.onload = function (oEvent) {
+            let blob = xhr.response;
+            if (blob) {
+                // https://gist.github.com/wemersonjanuario/45d302337b45d6aad866051f0473c646
+                if (navigator.appVersion.toString().indexOf('.NET') > 0) {
+                    //IE 10+
+                    window.navigator.msSaveBlob(blob, filename);
+                } else {
+                    //Firefox, Chrome
+                    let a = document.createElement("a");
+                    let blobUrl = window.URL.createObjectURL(new Blob([blob], { type: blob.type }));
+                    document.body.appendChild(a);
+                    a.style = "display: none";
+                    a.href = blobUrl;
+                    a.download = filename;
+                    a.click();
+                }
+            }
+            $('#filetable').datagrid('reload');
+        };
+        xhr.send();
+        return false;
+
+    } catch (e) {
+        alert(e);
+        return false;
+    }
+}
+
+$(function () {
+    "use strict";
+
+    var currentZipId = null;
+    let onSelectZipHandler = function (rowIndex, rowData) {
+        // 選択されたzipの情報を右ペインに表示するためのajax
+        $('#zip_comment_area').text(''); // ajax完了までは空白にしておく
+        let zipid = rowData['id'];
+        currentZipId = zipid;
+        $.ajax({
+            url: '/cgi-bin/zipinfo.php',
+            data: {
+                'zipid': zipid,
+                'contentsTypes': ['tag', 'parts', 'comment'],
+                'time': new Date().getTime()
+            },
+            type: 'GET',
+            dataType: 'json', // レスポンスをJSONとして解析
+        }).done(function (res) {
+            if (zipid === currentZipId) {
+                // ajax要求時点とzipidが歩変更なければ画面を更新する
+                // (変更があれば、このレスポンスは捨てる)
+                let msg = '';
+                if (res.tags && res.tags.length > 0) {
+                    msg += '<b>[タグ]</b>\r\n';
+                    $.each(res.tags, function (idx, tag) {
+                        msg += escapeHTML(tag) + '\r\n';
+                    });
+                    msg += '\r\n';
+                }
+                if (res.partsnames && res.partsnames.length > 0) {
+                    msg += '<b>[パーツ一覧]</b>\r\n';
+                    $.each(res.partsnames, function (idx, partsname) {
+                        msg += escapeHTML(partsname) + '\r\n';
+                    });
+                    msg += '\r\n';
+                }
+                if (res.comment) {
+                    msg += '<b>[コメント]</b>\r\n';
+                    msg += escapeHTML(res.comment) + '\r\n';
+                }
+                $('#zip_comment_area').html(msg);
+            }
+
+        }).fail(function (jqXHR, textStatus, errorThrown) {
+            $('#zip_comment_area').text(textStatus + ":" + errorThrown);
+        });
+    };
+
+    var currentPartsZipId = null;
+    let onSelectPartsZipHandler = function (rowIndex, rowData) {
+        // 選択されたzipの情報を右ペインに表示するためのajax
+        $('#zip_comment_area').text(''); // ajax完了までは空白にしておく
+        let zipid = rowData['id'];
+        if (currentPartsZipId != zipid) {
+            currentPartsZipId = zipid;
+            $.ajax({
+                url: '/cgi-bin/zipinfo.php',
+                data: {
+                    'zipid': zipid,
+                    'contentsTypes': ['comment'],
+                    'time': new Date().getTime()
+                },
+                type: 'GET',
+                dataType: 'json', // レスポンスをJSONとして解析
+            }).done(function (res) {
+                if (zipid === currentPartsZipId) {
+                    // ajax要求時点とzipidが歩変更なければ画面を更新する
+                    // (変更があれば、このレスポンスは捨てる)
+                    var msg = '';
+                    if (res.comment) {
+                        msg += escapeHTML(res.comment) + '\r\n';
+                    }
+                    $('#parts_comment_area').html(msg);
+                }
+            }).fail(function (jqXHR, textStatus, errorThrown) {
+                $('#parts_comment_area').text(textStatus + ":" + errorThrown);
+            });
+        }
+    };
+
+    let checked_tagids = {};
+    var request_search_parts = false;
+    let search_parts = function () {
+        if (!request_search_parts) {
+            return;
+        }
+        request_search_parts = false;
+        $('#partslist').datagrid('reload');
+    };
+
+    $('#taglist').datagrid({
+        pagination: true,
+        url: '/cgi-bin/taglist.php',
+        method: 'get',
+        remoteSort: true,
+        queryParams: {
+            tzoffset: -(new Date().getTimezoneOffset()), // GMTとの差
+            time: new Date().getTime()
+        },
+        toolbar: [{
+            text: 'リセット',
+            iconCls: 'icon-cancel',
+            handler: function () {
+                $('#taglist').datagrid('clearChecked');
+                Object.keys(checked_tagids).forEach(function (prop) {
+                    delete checked_tagids[prop];
+                });
+                request_search_parts = true;
+                setTimeout(search_parts, 1);
+            }
+        }],
+        onLoadSuccess: function () {
+            let rows = $(this).datagrid('getRows');
+            for (var i = 0; i < rows.length; i++) {
+                let tagid = rows[i].id;
+                if (checked_tagids[tagid]) {
+                    $(this).datagrid('checkRow', i);
+                }
+            }
+        },
+        onSelect: function (rowIndex, rowData) {
+            let tagid = rowData.id;
+            checked_tagids[tagid] = true;
+            request_search_parts = true;
+            setTimeout(search_parts, 1);
+        },
+        onUnselect: function (rowIndex, rowData) {
+            let tagid = rowData.id;
+            checked_tagids[tagid] = false;
+            request_search_parts = true;
+            setTimeout(search_parts, 1);
+        },
+        onSelectAll: function () {
+            let rows = $(this).datagrid('getRows');
+            for (var i = 0; i < rows.length; i++) {
+                let tagid = rows[i].id;
+                checked_tagids[tagid] = true;
+            }
+            request_search_parts = true;
+            setTimeout(search_parts, 1);
+        },
+        onUnselectAll: function () {
+            let rows = $(this).datagrid('getRows');
+            for (var i = 0; i < rows.length; i++) {
+                let tagid = rows[i].id;
+                checked_tagids[tagid] = false;
+            }
+            request_search_parts = true;
+            setTimeout(search_parts, 1);
+        },
+        pageList: [30, 60, 90, 120]
+    });
+
+    // ログイン済みクッキーがあるか? (有効かは問わない。表示切り替えのみ)
+    let hasCmjSid = document.cookie.indexOf('cmj-uploader-sid=') >= 0;
+
+    let doLogin = function () {
+        $.messager.confirm('ログイン',
+            'OSDNユーザとしてログインすることでアップロード制限を解除できます',
+            function (r) {
+                if (r) {
+                    let url = 'https://osdn.net/account/oauth2ui/authorize?' +
+                        'client_id=c10e635f25f1875ac9add68528a735d49dbd110ae15fe1e7b4d01eccc47db591&' +
+                        'response_type=code&' +
+                        'scope=profile&' +
+                        'state=' + window.location.hostname;
+                    window.location = url;
+                }
+            });
+    };
+
+    // ログインキー(Cookie)の表示
+    let showLogin = function () {
+        var cookies = document.cookie;
+        var result = [];
+        if (cookies != '') {
+            var cookieArray = cookies.split(';');
+            for (var i = 0; i < cookieArray.length; i++) {
+                var cookie = cookieArray[i].split('=');
+                result[cookie[0]] = decodeURIComponent(cookie[1]);
+            }
+        }
+        if (result['cmj-uploader-sid']) {
+            $('#txtSid').val(result['cmj-uploader-sid']);
+            $('#showSidDlg').dialog('open');
+        }
+    };
+
+    // ZIPファイル一覧用データグリッド
+    $('#filetable').datagrid({
+        pagination: true,
+        url: '/cgi-bin/ziplist.php',
+        method: 'get',
+        remoteSort: true,
+        toolbar: [{
+            text: 'アップロードする',
+            iconCls: 'icon-add',
+            handler: function () {
+                $('#upDlg').dialog('open');
+            }
+        }, '-', {
+            text: '削除する',
+            iconCls: 'icon-remove',
+            handler: function () {
+                let row = $('#filetable').datagrid('getSelected');
+                if (row) {
+                    $('input[name="selected_id"]').val(row['id']);
+                    $('#delDlg').dialog('open');
+                }
+            }
+        }, {
+            text: '通報する',
+            iconCls: 'icon-cancel',
+            handler: function () {
+                let row = $('#filetable').datagrid('getSelected');
+                if (row) {
+                    $('input[name="selected_id"]').val(row['id']);
+                    $('#report_zip_title').text(row['title']);
+                    $('#reportDlg').dialog('open');
+                }
+            }
+        }, {
+            text: (hasCmjSid ? 'ログイン中(キーの表示)' : 'アップロード制限の解除'),
+            iconCls: 'icon-edit',
+            handler: hasCmjSid ? showLogin : doLogin
+        }],
+        queryParams: {
+            tzoffset: -(new Date().getTimezoneOffset()), // GMTとの差
+            time: new Date().getTime()
+        },
+        onSelect: onSelectZipHandler,
+        pageList: [30, 60, 90, 120]
+    });
+
+    // パーツファイル一覧用データグリッド
+    $('#partslist').datagrid({
+        pagination: true,
+        url: '/cgi-bin/partslist.php',
+        method: 'post',
+        remoteSort: true,
+        queryParams: {
+            tzoffset: -(new Date().getTimezoneOffset()), // GMTとの差
+            time: new Date().getTime()
+        },
+        onBeforeLoad: function (param) {
+            var tagids = [];
+            Object.keys(checked_tagids).forEach(function (key) {
+                if (checked_tagids[key]) {
+                    tagids.push(key);
+                }
+            });
+            param.tagids = tagids;
+        },
+        onSelect: onSelectPartsZipHandler,
+        onLoadSuccess: function () {
+            currentPartsZipId = null;
+            $('#parts_comment_area').html('');
+        },
+        pageList: [30, 60, 90, 120]
+    });
+
+    // ファィルを選択したときにタイトルをファイル名から設定する
+    $('input[name="file"]').on('change', function () {
+        if (this.files.length > 0) {
+            let fname = this.files[0].name;
+            // 拡張子を除去する
+            let pos = fname.lastIndexOf('.');
+            if (pos > 0) {
+                fname = fname.substring(0, pos);
+            }
+            // ファイル名に$があれば削除キーとして使用する
+            pos = fname.lastIndexOf('$');
+            if (pos > 0) {
+                let delkey = fname.substring(pos + 1);
+                fname = fname.substring(0, pos);
+                $('input[name="delkey"').val(delkey);
+            }
+            // ファイル名に@があれば作者名として使用する
+            pos = fname.lastIndexOf('@');
+            if (pos > 0) {
+                let author = fname.substring(pos + 1);
+                fname = fname.substring(0, pos);
+                $('input[name="author"]').val(author);
+            }
+            // タイトルの設定
+            $('input[name="title"]').val(fname);
+        }
+    });
+
+    // 削除キーのリセット処理
+    let $form = $('form');
+    $form.on('reset', function () {
+        setTimeout(function () {
+            // delkeyが空欄であればランダムの割り当て
+            if ($('input[name="delkey"]').val() == '') {
+                let autoDelKey = Math.floor(Math.random() * 65535).toString(16);
+                $('input[name="delkey"]').val(autoDelKey);
+            }
+        }, 1);
+    });
+    $form[0].reset();
+
+    // 削除ダイアログの入力項目の活性制御
+    $('input[name="keytype"]:radio').on('change', function () {
+        let val = $(this).val();
+        if (val == 'TRIP') {
+            $('input[name="verifydelkey"]').attr('disabled', 'disabled');
+            $('input[name="verifytrip"]').removeAttr('disabled');
+        } else {
+            $('input[name="verifydelkey"]').removeAttr('disabled');
+            $('input[name="verifytrip"]').attr('disabled', 'disabled');
+        }
+    });
+
+    // ローカルストレージに記憶されているauthorとdelkeyを復元する
+    if (localStorage['uploadForm.author']) {
+        $('input[name="author"]').val(localStorage['uploadForm.author']);
+    }
+    if (localStorage['uploadForm.delkey']) {
+        $('input[name="delkey"]').val(localStorage['uploadForm.delkey']);
+    }
+    if (localStorage['uploadForm.license']) {
+        $('select[name="license"]').val(localStorage['uploadForm.license']);
+    }
+});
diff --git a/movetozips.sh b/movetozips.sh
new file mode 100644 (file)
index 0000000..577b5d6
--- /dev/null
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+(echo cd /storage/groups/c/ch/charactermanaj/zip
+find zip/ -type f | while read line
+do
+echo put $line
+done;) | sftp -b - seraphy@storage.osdn.net