$t ?? (strtotime((string)($obj['published'] ?? '')) ?: time()), 'type' => $booster ? 'boost' : 'note', 'object' => $id, 'actor' => $booster ?? $author, 'author' => $author, 'vis' => Fedi::visibility($to, $cc, Fedi::id(self::followersOf($author))), 'aud' => array_values(array_unique(array_merge($to, $cc))), 'local' => Fedi::isLocal($id), 'reply' => Fedi::id($obj['inReplyTo'] ?? null), 'tags' => $tags, ]; if ($booster) $e['t'] = $t ?? time(); self::rewrite(function(array $list) use ($e): array { // 同じ投稿(又は同じ人の同じブースト)は1回だけ foreach ($list as $x) { if ($x['type'] === $e['type'] && $x['object'] === $e['object'] && $x['actor'] === $e['actor']) return $list; } $list[] = $e; return $list; }); } /** * 編集された投稿のタグと宛先を更新する */ public static function refresh(array $obj): void { $id = Fedi::id($obj['id'] ?? null); if ($id === null) return; $tags = []; foreach ($obj['tag'] ?? [] as $tag) { if (is_array($tag) && ($tag['type'] ?? '') === 'Hashtag') $tags[] = mb_strtolower(ltrim((string)($tag['name'] ?? ''), '#')); } $aud = array_values(array_unique(array_merge(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? [])))); self::rewrite(fn(array $list) => array_map(fn($x) => $x['object'] === $id ? ['tags' => $tags, 'aud' => $aud] + $x : $x, $list)); } /** * 投稿(とそのブースト)を消す */ public static function remove(string $objectId, ?string $booster = null): void { self::rewrite(fn(array $list) => array_values(array_filter($list, function($x) use ($objectId, $booster) { if ($x['object'] !== $objectId) return true; return $booster !== null && !($x['type'] === 'boost' && $x['actor'] === $booster); }))); } /** * アクターの投稿を全部消す(アカウントの削除・ブロック) */ public static function removeActor(string $actor): void { self::rewrite(fn(array $list) => array_values(array_filter($list, fn($x) => $x['actor'] !== $actor && $x['author'] !== $actor))); } /** * 古い行を消す(cronのapcleanup)。ローカルの投稿は消さない * * @param int $days この日数より古いリモートの行を消す * @return array 消したリモートのオブジェクトのID */ public static function prune(int $days): array { $limit = time() - $days * 86400; $removed = []; self::rewrite(function(array $list) use ($limit, &$removed): array { $keep = []; foreach ($list as $x) { if (!$x['local'] && $x['t'] < $limit) $removed[] = $x['object']; else $keep[] = $x; } return $keep; }); return array_values(array_unique($removed)); } /** * 古いリモートのデータを消す(cronのapcleanup) * * @param int $days この日数より古いリモートの投稿とキャッシュを消す * @return \Result */ public static function cleanup(int $days = 90): \Result { $removed = self::prune($days); // 他の行(ブースト等)から使われていなければ、保存したオブジェクトも消す $used = []; foreach (self::entries() as $e) $used[$e['object']] = true; $n = 0; foreach ($removed as $id) { if (!isset($used[$id])) { Remote::forget($id); $n++; } } // タイムラインに無い古いオブジェクト(検索やプロフィールで取得した物) $limit = time() - $days * 86400; foreach (glob(Fedi::path('remote/objects/').'*.json') ?: [] as $f) { if (filemtime($f) < $limit) { $obj = Fedi::read($f, null); if (!$obj || !isset($used[$obj['id'] ?? ''])) { @unlink($f); $n++; } } } // 古いキャッシュ foreach (['remote/actors/', 'remote/webfinger/'] as $dir) { foreach (glob(Fedi::path($dir).'*.json') ?: [] as $f) if (filemtime($f) < $limit) @unlink($f); } // 画像のキャッシュ(期限切れ) $old = time() - Remote::IMAGE_TTL; foreach (glob(Fedi::path('remote/media/').'*') ?: [] as $f) if (filemtime($f) < $old) @unlink($f); return \Result::Success("古い投稿{$n}件を削除しました。", ['removed' => $n]); } /** * タイムラインを読む * * @param string $scope home, local, federated, user, actor, tag * @param string|null $viewer 見ているローカルのユーザー(未ログインならnull) * @param array $opt user/actor/tag:対象、before:この時刻より前、limit * @return array [項目の配列, 次のページのbefore(無ければnull)] * 項目:['entry' => 行, 'object' => Note] */ public static function query(string $scope, ?string $viewer, array $opt = []): array { $before = (int)($opt['before'] ?? PHP_INT_MAX); $limit = (int)($opt['limit'] ?? self::PAGE); $me = $viewer !== null ? Fedi::actorUrl($viewer) : null; $target = $opt['user'] ?? $opt['actor'] ?? null; if (isset($opt['user'])) $target = Fedi::actorUrl($opt['user']); $entries = self::entries(); usort($entries, fn($a, $b) => $b['t'] <=> $a['t']); $out = []; $next = null; $seen = []; foreach ($entries as $e) { if ($e['t'] >= $before) continue; $ok = match ($scope) { 'home' => $me !== null && ($e['actor'] === $me || $e['author'] === $me || in_array($me, $e['aud'], true) || Actor::isFollowing($viewer, $e['actor'])), 'local' => $e['local'] && $e['type'] === 'note' && $e['vis'] === 'public', 'federated' => $e['type'] === 'note' && $e['vis'] === 'public', 'user', 'actor' => $e['actor'] === $target && in_array($e['vis'], ['public', 'unlisted', 'followers', 'direct'], true), 'tag' => $e['type'] === 'note' && in_array(mb_strtolower((string)($opt['tag'] ?? '')), $e['tags'] ?? [], true) && in_array($e['vis'], ['public', 'unlisted'], true), default => false, }; if (!$ok || !self::canSee($viewer, $e)) continue; if ($viewer !== null && (Actor::isBlocked($viewer, $e['actor']) || Actor::isBlocked($viewer, $e['author']))) continue; if (Actor::isDomainBlocked($e['author'])) continue; // 同じ投稿は1回だけ(ブーストと元の投稿等) if (isset($seen[$e['object']])) continue; $obj = self::object($e['object']); if ($obj === null) continue; $seen[$e['object']] = true; if (count($out) >= $limit) { $next = $out[count($out) - 1]['entry']['t']; break; } $out[] = ['entry' => $e, 'object' => $obj]; } return [$out, $next]; } /** * 見ているユーザーがこの投稿を見られるか */ public static function canSee(?string $viewer, array $e): bool { if (in_array($e['vis'], ['public', 'unlisted'], true)) return true; if ($viewer === null) return false; $me = Fedi::actorUrl($viewer); if ($e['author'] === $me || in_array($me, $e['aud'], true)) return true; if ($e['vis'] !== 'followers') return false; // フォロワー限定:作者をフォローしている事 $author = $e['author']; $local = Fedi::localUsername($author); return $local !== null ? Actor::isFollowedBy($local, $me) : Actor::isFollowing($viewer, $author); } /** * 投稿のオブジェクト(ローカル又は保存しているリモート) */ public static function object(string $id): ?array { if (Fedi::isLocal($id)) { $ids = Fedi::localNoteId($id); $note = $ids ? Notes::get($ids[1]) : null; return $note ? Notes::toObject($note) : null; } return Remote::stored($id); } /** * ある投稿への返信(保存しているもの) */ public static function replies(string $id): array { $out = []; foreach (self::entries() as $e) { if ($e['type'] === 'note' && $e['reply'] === $id) $out[] = $e; } usort($out, fn($a, $b) => $a['t'] <=> $b['t']); return $out; } /** * 文字で検索する(ローカルと保存しているリモートの、公開・未収載の投稿) */ public static function search(string $q, ?string $viewer, int $limit = 40): array { $q = mb_strtolower(trim($q)); if ($q === '') return []; $entries = self::entries(); usort($entries, fn($a, $b) => $b['t'] <=> $a['t']); $out = []; foreach ($entries as $e) { if ($e['type'] !== 'note' || !self::canSee($viewer, $e)) continue; if ($viewer !== null && Actor::isBlocked($viewer, $e['author'])) continue; $obj = self::object($e['object']); if (!$obj) continue; $text = mb_strtolower(Html::text((string)($obj['content'] ?? '')).' '.($obj['summary'] ?? '')); if (!str_contains($text, $q)) continue; $out[] = ['entry' => $e, 'object' => $obj]; if (count($out) >= $limit) break; } return $out; } /////// private static function rewrite(callable $fn): void { $file = self::file(); $lock = fopen($file.'.lock', 'c'); if ($lock) flock($lock, LOCK_EX); try { $list = $fn(self::entries()); $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; $data = ''; foreach ($list as $e) $data .= json_encode($e, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"; file_put_contents($tmp, $data); rename($tmp, $file); } finally { if ($lock) { flock($lock, LOCK_UN); fclose($lock); } } } /** 投稿者のフォロワーのコレクション(分かれば) */ private static function followersOf(string $author): ?string { if (Fedi::isLocal($author)) return $author.'/followers'; $file = Fedi::path('remote/actors/'.sha1($author).'.json'); $cached = Fedi::read($file, null); return $cached['doc']['followers'] ?? null; } }