'as:Hashtag', 'sensitive' => 'as:sensitive', 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable', ], ]; /** 投稿の最大文字数 */ public const MAX_LENGTH = 5000; /** 1つの投稿の最大の添付数 */ public const MAX_ATTACHMENTS = 4; /** @var string|null 保存先(テスト用。既定はdata/ap) */ public static ?string $dataDir = null; /** @var string|null ユーザーデータ(テスト用。既定はdata/user) */ public static ?string $userDir = null; /** @var string|null サイトのURL(テスト用。既定はFEDIINFOのdomain又はHTTP_HOST) */ public static ?string $baseUrl = null; /** * マルチユーザーモードか */ public static function enabled(): bool { return ACTIVITYPUB_MULTIUSER; } /** * サイトのURL(末尾の「/」なし) */ public static function base(): string { if (self::$baseUrl !== null) return self::$baseUrl; $domain = self::configuredDomain(); if ($domain !== '') return $domain; $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; return ($https ? 'https' : 'http').'://'.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000'); } /** * FEDIINFOの「domain」をURLにする(「example.com」だけでも「https://example.com」にする) * * @return string 末尾の「/」なし、未設定なら空 */ public static function configuredDomain(): string { return self::normalizeDomain((string)(defined('FEDIINFO') ? (FEDIINFO['domain'] ?? '') : '')); } public static function normalizeDomain(string $domain): string { $domain = rtrim(trim($domain), '/'); if ($domain === '') return ''; // 以前はスキームが無いと「example.com/ap/actor」の様な不正なIDになった if (!preg_match('#^https?://#i', $domain)) $domain = 'https://'.$domain; return $domain; } public static function host(): string { return (string)parse_url(self::base(), PHP_URL_HOST); } public static function actorUrl(string $username): string { return self::base().'/ap/users/'.$username; } public static function noteUrl(string $username, string $id): string { return self::actorUrl($username).'/notes/'.$id; } /** ブログのアクター */ public static function blogActorUrl(): string { return self::base().'/ap/actor'; } public static function sharedInbox(): string { return self::base().'/ap/inbox'; } /** このサイトのURLか */ public static function isLocal(string $url): bool { $base = self::base(); return $url === $base || str_starts_with($url, $base.'/'); } /** * ローカルのアクターのURLからユーザー名を取り出す * * @return string|null ローカルのユーザーでなければnull */ public static function localUsername(string $actorUrl): ?string { $prefix = self::base().'/ap/users/'; if (!str_starts_with($actorUrl, $prefix)) return null; $name = substr($actorUrl, strlen($prefix)); return preg_match('/^[A-Za-z0-9]+$/', $name) ? $name : null; } /** * ローカルの投稿のURLからIDを取り出す * * @return array|null [ユーザー名, ID] */ public static function localNoteId(string $url): ?array { if (!preg_match('#^'.preg_quote(self::base().'/ap/users/', '#').'([A-Za-z0-9]+)/notes/([0-9a-f]{20})$#', $url, $m)) return null; return [$m[1], $m[2]]; } /** URLのホスト名(小文字) */ public static function domainOf(string $url): string { return strtolower((string)parse_url($url, PHP_URL_HOST)); } //////// ユーザー /** * ローカルのユーザーのデータ(パスワード等を除く) * * @param string $username ユーザー名 * @return \stdClass|null */ public static function localUser(string $username): ?\stdClass { if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return null; $dir = self::$userDir ?? ROOT.'/data/user/'; foreach (glob($dir.'*.'.$username.'.json') ?: [] as $f) { if (!preg_match('/^\d+\.'.$username.'\.json$/', basename($f))) continue; $u = json_decode((string)file_get_contents($f)); if (!$u instanceof \stdClass || ($u->username ?? '') !== $username) continue; unset($u->password, $u->tokens, $u->verifyToken, $u->twoFactor, $u->twoFactorPending, $u->twoFactorTicket); return $u; } return null; } /** * Fediverseのアクターとして使えるローカルのユーザー(ロールがあり、メール確認済み) */ public static function localActor(string $username): ?\stdClass { $u = self::localUser($username); if (!$u || !can_federate($u) || (($u->verified ?? true) === false)) return null; return $u; } //////// ファイル /** * data/ap/の下のパス(ディレクトリは作る) */ public static function path(string $rel): string { $base = rtrim(self::$dataDir ?? ROOT.'/data/ap', '/'); $path = $base.'/'.ltrim($rel, '/'); $dir = dirname($path); if (!is_dir($dir)) @mkdir($dir, 0700, true); return $path; } public static function userPath(string $username, string $file): string { return self::path("users/{$username}/{$file}"); } /** * JSONファイルを読む */ public static function read(string $path, mixed $default = []): mixed { if (!is_file($path)) return $default; $data = json_decode((string)file_get_contents($path), true); return $data ?? $default; } /** * JSONファイルを書く(一時ファイルから名前を変えるので、途中で読まれても壊れない) */ public static function write(string $path, mixed $data): bool { $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); if ($json === false || file_put_contents($tmp, $json) === false) return false; if (!rename($tmp, $path)) { @unlink($tmp); return false; } return true; } /** * JSONファイルをロックして読み書きする * * @param string $path ファイル * @param callable $fn データを受け取り、新しいデータを返す * @param mixed $default ファイルが無い場合のデータ * @return mixed 新しいデータ */ public static function update(string $path, callable $fn, mixed $default = []): mixed { $lock = fopen($path.'.lock', 'c'); if ($lock) flock($lock, LOCK_EX); try { $new = $fn(self::read($path, $default)); self::write($path, $new); return $new; } finally { if ($lock) { flock($lock, LOCK_UN); fclose($lock); } } } /** * 時刻順に並ぶID(20文字の16進数) */ public static function newId(): string { return sprintf('%012x', (int)(microtime(true) * 1000)).bin2hex(random_bytes(4)); } /** ISO 8601(UTC) */ public static function now(?int $t = null): string { return gmdate('Y-m-d\TH:i:s\Z', $t ?? time()); } /** * 宛先からの公開範囲 * * @param array $to to * @param array $cc cc * @param string|null $followers 投稿者のフォロワーのコレクション * @return string public, unlisted, followers, direct */ public static function visibility(array $to, array $cc, ?string $followers = null): string { if (in_array(self::PUBLIC, $to, true) || in_array('as:Public', $to, true) || in_array('Public', $to, true)) return 'public'; if (in_array(self::PUBLIC, $cc, true) || in_array('as:Public', $cc, true) || in_array('Public', $cc, true)) return 'unlisted'; $all = array_merge($to, $cc); if ($followers !== null && in_array($followers, $all, true)) return 'followers'; foreach ($all as $a) if (is_string($a) && str_ends_with($a, '/followers')) return 'followers'; return 'direct'; } /** * IDの配列にする(文字列、オブジェクト、配列のどれでも) */ public static function ids(mixed $v): array { if ($v === null) return []; if (is_string($v)) return [$v]; if (is_array($v) && isset($v['id']) && is_string($v['id'])) return [$v['id']]; if (is_array($v) && array_is_list($v)) { $out = []; foreach ($v as $x) $out = array_merge($out, self::ids($x)); return $out; } return []; } /** オブジェクトのID(文字列かオブジェクト) */ public static function id(mixed $v): ?string { return self::ids($v)[0] ?? null; } }