| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, and/or sell copies of the Software, subject to the following conditions: |
| 10 | |
| 11 | 1. **Origin Attribution**: |
| 12 | - You must not misrepresent the origin of the Software; you must not claim |
| 13 | you created the original Software. |
| 14 | - If the Software is used in a product, you must either: |
| 15 | a. Provide clear attribution in the product's documentation, user interface, |
| 16 | or other visible areas, **OR** |
| 17 | b. Pay the original developers a fee they specify in writing. |
| 18 | 2. **Usage Restriction**: |
| 19 | - The Software, or any derivative works, dependencies, or libraries |
| 20 | incorporating it, must not be used for censorship or to suppress freedom of |
| 21 | speech, expression, or creativity. Prohibited uses include, but are not |
| 22 | limited to: |
| 23 | - Censorship of so-called "hate speech", visuals, non-mainstream opinions, |
| 24 | ideas, or objective reality. |
| 25 | - Tools or systems designed to restrict access to information or |
| 26 | artistic works. |
| 27 | 3. **Notice Preservation**: |
| 28 | - This license and the above copyright notice must remain intact in all copies |
| 29 | of the source code. |
| 30 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib; |
| 39 | |
| 40 | use Std\Lib\Fedi\Fedi; |
| 41 | use Result; |
| 42 | |
| 43 | /** |
| 44 | * サイトの通知(ヘッダーの「通知」)とメールの待ち行列 |
| 45 | * |
| 46 | * データはdata/notifications/: |
| 47 | * {ユーザー名}.json 通知(新しい順、MAX件まで) |
| 48 | * mail/{ID}.json 送るメール(cronのnotifyが送る) |
| 49 | * |
| 50 | * 記事のコメントの通知(Comments)は表示する時に中身を探す。他の物(Gitler等)はlabel・page・excerptを入れて追加する。 |
| 51 | */ |
| 52 | final class Notifications { |
| 53 | /** 1人の通知の最大数 */ |
| 54 | public const MAX = 200; |
| 55 | /** メールの再試行の回数 */ |
| 56 | private const MAIL_TRIES = 5; |
| 57 | |
| 58 | /** @var string|null 保存先(テスト用。既定はdata) */ |
| 59 | public static ?string $dataDir = null; |
| 60 | |
| 61 | /** |
| 62 | * 通知(新しい順) |
| 63 | * |
| 64 | * @return array type、from、at、read等 |
| 65 | */ |
| 66 | public static function list(string $username): array { |
| 67 | if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return []; |
| 68 | return Fedi::read(self::path("notifications/{$username}.json")); |
| 69 | } |
| 70 | |
| 71 | public static function unreadCount(string $username): int { |
| 72 | return count(array_filter(self::list($username), fn($n) => empty($n['read']))); |
| 73 | } |
| 74 | |
| 75 | public static function markRead(string $username): void { |
| 76 | if (self::unreadCount($username) === 0) return; |
| 77 | Fedi::update(self::path("notifications/{$username}.json"), fn($l) => array_map(fn($n) => ['read' => true] + $n, $l)); |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * 通知を追加する |
| 82 | * |
| 83 | * @param array $n type、from(username、又はactor・name・handle・url・icon)等。label・page・excerptが有れば表示にそのまま使う |
| 84 | * @param callable|null $same 未読の同じ通知が有れば追加しない(fn(array $old): bool) |
| 85 | */ |
| 86 | public static function add(string $username, array $n, ?callable $same = null): void { |
| 87 | if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return; |
| 88 | Fedi::update(self::path("notifications/{$username}.json"), function(array $l) use ($n, $same): array { |
| 89 | if ($same !== null) foreach ($l as $old) if (empty($old['read']) && $same($old)) return $l; |
| 90 | array_unshift($l, $n + ['at' => time(), 'read' => false]); |
| 91 | return array_slice($l, 0, self::MAX); |
| 92 | }); |
| 93 | } |
| 94 | |
| 95 | //////// メール |
| 96 | |
| 97 | /** メールを待ち行列に入れる(MAILER_ENABLEDでなければ何もしない) */ |
| 98 | public static function queueMail(string $to, string $subject, string $body, string $toName = ''): void { |
| 99 | if (!MAILER_ENABLED || !filter_var($to, FILTER_VALIDATE_EMAIL)) return; |
| 100 | Fedi::write(self::path('notifications/mail/'.Fedi::newId().'.json'), ['to' => $to, 'subject' => $subject, 'body' => $body, 'name' => $toName, 'tries' => 0]); |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * 待っているメールを送る(cronのnotify) |
| 105 | * |
| 106 | * @param int $budget この秒数を超えたら次に回す |
| 107 | */ |
| 108 | public static function sendMail(int $budget = 50): Result { |
| 109 | if (!MAILER_ENABLED) return Result::Success('メールは無効です。'); |
| 110 | $start = time(); |
| 111 | $sent = $failed = 0; |
| 112 | $files = glob(self::path('notifications/mail/').'*.json') ?: []; |
| 113 | sort($files); |
| 114 | foreach ($files as $file) { |
| 115 | if (time() - $start > $budget) break; |
| 116 | // 同時に動いた別のcronと重ならない様に、名前を変えて取る |
| 117 | $work = $file.'.work'; |
| 118 | if (!@rename($file, $work)) continue; |
| 119 | $m = Fedi::read($work, null); |
| 120 | if (!is_array($m) || !is_string($m['to'] ?? null)) { |
| 121 | @unlink($work); |
| 122 | continue; |
| 123 | } |
| 124 | if (Mailer::deliver($m['to'], (string)$m['subject'], (string)$m['body'], (string)($m['name'] ?? '') ?: null)->isSuccess) { |
| 125 | @unlink($work); |
| 126 | $sent++; |
| 127 | continue; |
| 128 | } |
| 129 | $failed++; |
| 130 | $m['tries'] = (int)($m['tries'] ?? 0) + 1; |
| 131 | if ($m['tries'] < self::MAIL_TRIES) Fedi::write($file, $m); |
| 132 | @unlink($work); |
| 133 | } |
| 134 | return Result::Success("メール:送信{$sent}件、失敗{$failed}件", ['sent' => $sent, 'failed' => $failed]); |
| 135 | } |
| 136 | |
| 137 | private static function path(string $rel): string { |
| 138 | $path = rtrim(self::$dataDir ?? ROOT.'/data', '/').'/'.$rel; |
| 139 | $dir = dirname($path); |
| 140 | if (!is_dir($dir)) @mkdir($dir, 0700, true); |
| 141 | return $path; |
| 142 | } |
| 143 | } |