Revert "Release 20200823.1100"

This commit is contained in:
shibafu
2020-08-23 12:28:21 +09:00
committed by GitHub
parent f377b143d2
commit 1b5f690f4e
71 changed files with 1200 additions and 4372 deletions

View File

@@ -1,39 +0,0 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class CheckinWebhook extends Model
{
use SoftDeletes;
/** @var int ユーザーごとの作成数制限 */
const PER_USER_LIMIT = 10;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['name'];
protected static function boot()
{
parent::boot();
self::creating(function (CheckinWebhook $webhook) {
$webhook->id = Str::random(64);
});
}
public function user()
{
return $this->belongsTo(User::class);
}
public function isAvailable()
{
return $this->user !== null;
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class ContentProvider extends Model
{
public $incrementing = false;
protected $primaryKey = 'host';
protected $keyType = 'string';
protected $fillable = [
'host',
'robots',
'robots_cached_at',
];
protected $dates = [
'created_at',
'updated_at',
'robots_cached_at',
];
}

View File

@@ -14,13 +14,11 @@ class Ejaculation extends Model
const SOURCE_WEB = 'web';
const SOURCE_CSV = 'csv';
const SOURCE_WEBHOOK = 'webhook';
protected $fillable = [
'user_id', 'ejaculated_date',
'note', 'geo_latitude', 'geo_longitude', 'link', 'source',
'is_private', 'is_too_sensitive',
'checkin_webhook_id'
'is_private', 'is_too_sensitive'
];
protected $dates = [
@@ -49,9 +47,9 @@ class Ejaculation extends Model
return $this->hasMany(Like::class);
}
public function scopeVisibleToTimeline(Builder $query)
public function scopeOnlyWebCheckin(Builder $query)
{
return $query->whereIn('ejaculations.source', [Ejaculation::SOURCE_WEB, Ejaculation::SOURCE_WEBHOOK]);
return $query->where('ejaculations.source', Ejaculation::SOURCE_WEB);
}
public function scopeWithLikes(Builder $query)

View File

@@ -4,10 +4,7 @@ namespace App\Exceptions;
use Exception;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class Handler extends ExceptionHandler
{
@@ -23,7 +20,6 @@ class Handler extends ExceptionHandler
\Illuminate\Database\Eloquent\ModelNotFoundException::class,
\Illuminate\Session\TokenMismatchException::class,
\Illuminate\Validation\ValidationException::class,
\App\MetadataResolver\ResolverCircuitBreakException::class,
];
/**
@@ -72,28 +68,4 @@ class Handler extends ExceptionHandler
return redirect()->guest(route('login'));
}
protected function prepareException(Exception $e)
{
if (!config('app.debug') && $e instanceof ModelNotFoundException) {
return new NotFoundHttpException('Resource not found.', $e);
}
return parent::prepareException($e);
}
protected function prepareJsonResponse($request, Exception $e)
{
$status = $this->isHttpException($e) ? $e->getStatusCode() : 500;
return new JsonResponse(
[
'status' => $status,
'error' => $this->convertExceptionToArray($e),
],
$status,
$this->isHttpException($e) ? $e->getHeaders() : [],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
);
}
}

View File

@@ -1,103 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\CheckinWebhook;
use App\Ejaculation;
use App\Events\LinkDiscovered;
use App\Http\Controllers\Controller;
use App\Http\Resources\EjaculationResource;
use App\Tag;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class WebhookController extends Controller
{
public function checkin(CheckinWebhook $webhook, Request $request)
{
if (!$webhook->isAvailable()) {
return response()->json([
'status' => 404,
'error' => [
'message' => 'The webhook is unavailable'
]
], 404);
}
$validator = Validator::make($request->all(), [
'checked_in_at' => 'nullable|date|after_or_equal:2000-01-01 00:00:00|before_or_equal:2099-12-31 23:59:59',
'note' => 'nullable|string|max:500',
'link' => 'nullable|url|max:2000',
'tags' => 'nullable|array',
'tags.*' => ['string', 'not_regex:/[\s\r\n]/u', 'max:255'],
'is_private' => 'nullable|boolean',
'is_too_sensitive' => 'nullable|boolean',
], [
'tags.*.not_regex' => 'The :attribute cannot contain spaces, tabs and newlines.'
]);
try {
$inputs = $validator->validate();
} catch (ValidationException $e) {
return response()->json([
'status' => 422,
'error' => [
'message' => 'Validation failed',
'violations' => $validator->errors()->all(),
]
], 422);
}
$ejaculatedDate = empty($inputs['checked_in_at']) ? now() : new Carbon($inputs['checked_in_at']);
$ejaculatedDate = $ejaculatedDate->setTimezone(date_default_timezone_get())->startOfMinute();
if (Ejaculation::where(['user_id' => $webhook->user_id, 'ejaculated_date' => $ejaculatedDate])->count()) {
return response()->json([
'status' => 422,
'error' => [
'message' => 'Checkin already exists in this time',
]
], 422);
}
$ejaculation = DB::transaction(function () use ($inputs, $webhook, $ejaculatedDate) {
$ejaculation = Ejaculation::create([
'user_id' => $webhook->user_id,
'ejaculated_date' => $ejaculatedDate,
'note' => $inputs['note'] ?? '',
'link' => $inputs['link'] ?? '',
'source' => Ejaculation::SOURCE_WEBHOOK,
'is_private' => (bool)($inputs['is_private'] ?? false),
'is_too_sensitive' => (bool)($inputs['is_too_sensitive'] ?? false),
'checkin_webhook_id' => $webhook->id
]);
$tagIds = [];
if (!empty($inputs['tags'])) {
foreach ($inputs['tags'] as $tag) {
$tag = trim($tag);
if ($tag === '') {
continue;
}
$tag = Tag::firstOrCreate(['name' => $tag]);
$tagIds[] = $tag->id;
}
}
$ejaculation->tags()->sync($tagIds);
return $ejaculation;
});
if (!empty($ejaculation->link)) {
event(new LinkDiscovered($ejaculation->link));
}
return response()->json([
'status' => 200,
'checkin' => new EjaculationResource($ejaculation)
]);
}
}

View File

@@ -16,26 +16,17 @@ class EjaculationController extends Controller
{
public function create(Request $request)
{
$tags = old('tags') ?? $request->input('tags', '');
if (!empty($tags)) {
$tags = explode(' ', $tags);
}
$errors = $request->session()->get('errors');
$initialState = [
'fields' => [
'date' => old('date') ?? $request->input('date', date('Y/m/d')),
'time' => old('time') ?? $request->input('time', date('H:i')),
'link' => old('link') ?? $request->input('link', ''),
'tags' => $tags,
'note' => old('note') ?? $request->input('note', ''),
'is_private' => old('is_private') ?? $request->input('is_private', 0) == 1,
'is_too_sensitive' => old('is_too_sensitive') ?? $request->input('is_too_sensitive', 0) == 1
],
'errors' => isset($errors) ? $errors->getMessages() : null
$defaults = [
'date' => $request->input('date', date('Y/m/d')),
'time' => $request->input('time', date('H:i')),
'link' => $request->input('link', ''),
'tags' => $request->input('tags', ''),
'note' => $request->input('note', ''),
'is_private' => $request->input('is_private', 0) == 1,
'is_too_sensitive' => $request->input('is_too_sensitive', 0) == 1
];
return view('ejaculation.checkin')->with('initialState', $initialState);
return view('ejaculation.checkin')->with('defaults', $defaults);
}
public function store(Request $request)
@@ -121,36 +112,13 @@ class EjaculationController extends Controller
return view('ejaculation.show')->with(compact('user', 'ejaculation', 'ejaculatedSpan'));
}
public function edit(Request $request, $id)
public function edit($id)
{
$ejaculation = Ejaculation::findOrFail($id);
$this->authorize('edit', $ejaculation);
if (old('tags') === null) {
$tags = $ejaculation->tags->pluck('name');
} else {
$tags = old('tags');
if (!empty($tags)) {
$tags = explode(' ', $tags);
}
}
$errors = $request->session()->get('errors');
$initialState = [
'fields' => [
'date' => old('date') ?? $ejaculation->ejaculated_date->format('Y/m/d'),
'time' => old('time') ?? $ejaculation->ejaculated_date->format('H:i'),
'link' => old('link') ?? $ejaculation->link,
'tags' => $tags,
'note' => old('note') ?? $ejaculation->note,
'is_private' => is_bool(old('is_private')) ? old('is_private') : $ejaculation->note,
'is_too_sensitive' => is_bool(old('is_too_sensitive')) ? old('is_too_sensitive') : $ejaculation->is_too_sensitive
],
'errors' => isset($errors) ? $errors->getMessages() : null
];
return view('ejaculation.edit')->with(compact('ejaculation', 'initialState'));
return view('ejaculation.edit')->with(compact('ejaculation'));
}
public function update(Request $request, $id)

View File

@@ -71,7 +71,7 @@ SQL
->select('ejaculations.*')
->with('user', 'tags')
->withLikes()
->visibleToTimeline()
->onlyWebCheckin()
->take(21)
->get();

View File

@@ -2,7 +2,6 @@
namespace App\Http\Controllers;
use App\CheckinWebhook;
use App\DeactivatedUser;
use App\Ejaculation;
use App\Exceptions\CsvImportException;
@@ -76,46 +75,6 @@ class SettingController extends Controller
return redirect()->route('setting.privacy')->with('status', 'プライバシー設定を更新しました。');
}
public function webhooks()
{
$webhooks = Auth::user()->checkinWebhooks;
$webhooksLimit = CheckinWebhook::PER_USER_LIMIT;
return view('setting.webhooks')->with(compact('webhooks', 'webhooksLimit'));
}
public function storeWebhooks(Request $request)
{
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
Rule::unique('checkin_webhooks', 'name')->where(function ($query) {
return $query->where('user_id', Auth::id());
})
]
], [], [
'name' => '名前'
]);
if (Auth::user()->checkinWebhooks()->count() >= CheckinWebhook::PER_USER_LIMIT) {
return redirect()->route('setting.webhooks')
->with('status', CheckinWebhook::PER_USER_LIMIT . '件以上のWebhookを作成することはできません。');
}
Auth::user()->checkinWebhooks()->create($validated);
return redirect()->route('setting.webhooks')->with('status', '作成しました。');
}
public function destroyWebhooks(CheckinWebhook $webhook)
{
$webhook->delete();
return redirect()->route('setting.webhooks')->with('status', '削除しました。');
}
public function import()
{
return view('setting.import');

View File

@@ -19,7 +19,7 @@ class TimelineController extends Controller
->select('ejaculations.*')
->with('user', 'tags')
->withLikes()
->visibleToTimeline()
->onlyWebCheckin()
->paginate(21);
return view('timeline.public')->with(compact('ejaculations'));

View File

@@ -38,17 +38,15 @@ class Kernel extends HttpKernel
\App\Http\Middleware\NormalizeLineEnding::class,
],
// 現時点では内部APIしかないので、認証の手間を省くためにステートフルにしている。
'api' => [
\App\Http\Middleware\EnforceJson::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'stateful' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
]
'throttle:60,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
/**
* Request headerに Accept: application/json を上書きする。APIエンドポイント用。
*/
class EnforceJson
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class EjaculationResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return [
'id' => $this->id,
'checked_in_at' => $this->ejaculated_date->format(\DateTime::ATOM),
'note' => $this->note,
'link' => $this->link,
'tags' => $this->tags->pluck('name'),
'source' => $this->source,
'is_private' => $this->is_private,
'is_too_sensitive' => $this->is_too_sensitive,
];
}
}

View File

@@ -2,8 +2,6 @@
namespace App;
use Carbon\CarbonInterface;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Database\Eloquent\Model;
class Metadata extends Model
@@ -15,66 +13,10 @@ class Metadata extends Model
protected $fillable = ['url', 'title', 'description', 'image', 'expires_at'];
protected $visible = ['url', 'title', 'description', 'image', 'expires_at', 'tags'];
protected $dates = ['created_at', 'updated_at', 'expires_at', 'error_at'];
protected $dates = ['created_at', 'updated_at', 'expires_at'];
public function tags()
{
return $this->belongsToMany(Tag::class)->withTimestamps();
}
public function needRefresh(): bool
{
return $this->isExpired() || $this->error_at !== null;
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at < now();
}
public function storeException(CarbonInterface $error_at, \Exception $exception): self
{
$this->prepareFieldsOnError();
$this->error_at = $error_at;
$this->error_exception_class = get_class($exception);
$this->error_body = $exception->getMessage();
if ($exception instanceof RequestException) {
$this->error_http_code = $exception->getCode();
} else {
$this->error_http_code = null;
}
$this->error_count++;
return $this;
}
public function storeError(CarbonInterface $error_at, string $body, ?int $httpCode = null): self
{
$this->prepareFieldsOnError();
$this->error_at = $error_at;
$this->error_exception_class = null;
$this->error_body = $body;
$this->error_http_code = $httpCode;
$this->error_count++;
return $this;
}
public function clearError(): self
{
$this->error_at = null;
$this->error_exception_class = null;
$this->error_body = null;
$this->error_http_code = null;
$this->error_count = 0;
return $this;
}
private function prepareFieldsOnError()
{
$this->title = $this->title ?? '';
$this->description = $this->description ?? '';
$this->image = $this->image ?? '';
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace App\MetadataResolver;
use RuntimeException;
use Throwable;
/**
* ContentProviderの提供するrobots.txtによってクロールが拒否された場合にスローされます。
*/
class DisallowedByProviderException extends RuntimeException
{
private $url;
public function __construct(string $url, Throwable $previous = null)
{
parent::__construct("Access denied by robots.txt: $url", 0, $previous);
$this->url = $url;
}
public function getUrl(): string
{
return $this->url;
}
public function getHost(): string
{
return parse_url($this->url, PHP_URL_HOST);
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\MetadataResolver;
use Throwable;
/**
* 規定回数以上の解決失敗により、メタデータの取得が不能となっている場合にスローされます。
*/
class ResolverCircuitBreakException extends \RuntimeException
{
public function __construct(int $errorCount, string $url, Throwable $previous = null)
{
parent::__construct("{$errorCount}回失敗しているためメタデータの取得を中断しました: {$url}", 0, $previous);
}
}

View File

@@ -1,10 +0,0 @@
<?php
namespace App\MetadataResolver;
/**
* MetadataResolver内で未キャッチの例外が発生した場合にスローされます。
*/
class UncaughtResolverException extends \RuntimeException
{
}

View File

@@ -1,62 +0,0 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class FuzzyBoolean implements Rule
{
public static function isTruthy($value): bool
{
if ($value === 1 || $value === '1') {
return true;
}
$lower = strtolower((string)$value);
return $lower === 'true';
}
public static function isFalsy($value): bool
{
if ($value === null || $value === '' || $value === 0 || $value === '0') {
return true;
}
$lower = strtolower((string)$value);
return $lower === 'false';
}
/**
* Create a new rule instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value)
{
return self::isTruthy($value) || self::isFalsy($value);
}
/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return __('validation.boolean');
}
}

View File

@@ -30,7 +30,7 @@ class CheckinCsvExporter
$csv->addStreamFilter('convert.mbstring.encoding.UTF-8:SJIS-win');
}
$header = ['日時', 'ノート', 'オカズリンク', '非公開', 'センシティブ'];
$header = ['日時', 'ノート', 'オカズリンク'];
for ($i = 1; $i <= 32; $i++) {
$header[] = "タグ{$i}";
}
@@ -45,8 +45,6 @@ class CheckinCsvExporter
$ejaculation->ejaculated_date->format('Y/m/d H:i'),
$ejaculation->note,
$ejaculation->link,
self::formatBoolean($ejaculation->is_private),
self::formatBoolean($ejaculation->is_too_sensitive),
];
foreach ($ejaculation->tags->take(32) as $tag) {
$record[] = $tag->name;
@@ -56,9 +54,4 @@ class CheckinCsvExporter
});
});
}
private static function formatBoolean($value): string
{
return $value ? 'true' : 'false';
}
}

View File

@@ -6,7 +6,6 @@ namespace App\Services;
use App\Ejaculation;
use App\Exceptions\CsvImportException;
use App\Rules\CsvDateTime;
use App\Rules\FuzzyBoolean;
use App\Tag;
use App\User;
use Carbon\Carbon;
@@ -76,8 +75,6 @@ class CheckinCsvImporter
'日時' => ['required', new CsvDateTime()],
'ノート' => 'nullable|string|max:500',
'オカズリンク' => 'nullable|url|max:2000',
'非公開' => ['nullable', new FuzzyBoolean()],
'センシティブ' => ['nullable', new FuzzyBoolean()],
]);
if ($validator->fails()) {
@@ -91,12 +88,6 @@ class CheckinCsvImporter
$ejaculation->note = str_replace(["\r\n", "\r"], "\n", $record['ノート'] ?? '');
$ejaculation->link = $record['オカズリンク'] ?? '';
$ejaculation->source = Ejaculation::SOURCE_CSV;
if (isset($record['非公開'])) {
$ejaculation->is_private = FuzzyBoolean::isTruthy($record['非公開']);
}
if (isset($record['センシティブ'])) {
$ejaculation->is_too_sensitive = FuzzyBoolean::isTruthy($record['センシティブ']);
}
try {
$tags = $this->parseTags($line, $record);

View File

@@ -2,26 +2,17 @@
namespace App\Services;
use App\ContentProvider;
use App\Metadata;
use App\MetadataResolver\DeniedHostException;
use App\MetadataResolver\DisallowedByProviderException;
use App\MetadataResolver\MetadataResolver;
use App\MetadataResolver\ResolverCircuitBreakException;
use App\MetadataResolver\UncaughtResolverException;
use App\Tag;
use App\Utilities\Formatter;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class MetadataResolveService
{
/** @var int メタデータの解決を中断するエラー回数。この回数以上エラーしていたら処理は行わない。 */
const CIRCUIT_BREAK_COUNT = 5;
/** @var MetadataResolver */
private $resolver;
/** @var Formatter */
@@ -33,13 +24,6 @@ class MetadataResolveService
$this->formatter = $formatter;
}
/**
* メタデータをキャッシュまたはリモートに問い合わせて取得します。
* @param string $url メタデータを取得したいURL
* @return Metadata 取得できたメタデータ
* @throws DeniedHostException アクセス先がブラックリスト入りしているため取得できなかった場合にスロー
* @throws UncaughtResolverException Resolver内で例外が発生して取得できなかった場合にスロー
*/
public function execute(string $url): Metadata
{
// URLの正規化
@@ -50,252 +34,34 @@ class MetadataResolveService
throw new DeniedHostException($url);
}
$metadata = Metadata::find($url);
return DB::transaction(function () use ($url) {
// 無かったら取得
// TODO: ある程度古かったら再取得とかありだと思う
$metadata = Metadata::find($url);
if ($metadata == null || ($metadata->expires_at !== null && $metadata->expires_at < now())) {
try {
$resolved = $this->resolver->resolve($url);
$metadata = Metadata::updateOrCreate(['url' => $url], [
'title' => $resolved->title,
'description' => $resolved->description,
'image' => $resolved->image,
'expires_at' => $resolved->expires_at
]);
// 無かったら取得
// TODO: ある程度古かったら再取得とかありだと思う
if ($metadata == null || $metadata->needRefresh()) {
$hostWithPort = $this->getHostWithPortFromUrl($url);
$metadata = $this->hostLock($hostWithPort, function (?CarbonInterface $lastAccess) use ($url) {
// HostLockの解放待ちをしている間に、他のプロセスで取得完了しているかもしれない
$metadata = Metadata::find($url);
if ($metadata !== null && !$metadata->needRefresh()) {
return $metadata;
}
$this->checkProviderPolicy($url, $lastAccess);
return $this->resolve($url, $metadata);
});
}
return $metadata;
}
/**
* URLからホスト部とポート部を抽出
* @param string $url
* @return string
*/
private function getHostWithPortFromUrl(string $url): string
{
$parts = parse_url($url);
$host = $parts['host'];
if (isset($parts['port'])) {
$host .= ':' . $parts['port'];
}
return $host;
}
/**
* アクセス先ホスト単位の排他ロックを取って処理を実行
* @param string $host
* @param callable $fn
* @return mixed return of $fn
* @throws \RuntimeException いろいろな死に方をする
*/
private function hostLock(string $host, callable $fn)
{
$lockDir = storage_path('content_providers_lock');
if (!file_exists($lockDir)) {
if (!mkdir($lockDir)) {
throw new \RuntimeException("Lock failed! Can't create lock directory.");
}
}
$lockFile = $lockDir . DIRECTORY_SEPARATOR . $host;
$fp = fopen($lockFile, 'c+b');
if ($fp === false) {
throw new \RuntimeException("Lock failed! Can't open lock file.");
}
try {
if (!flock($fp, LOCK_EX)) {
throw new \RuntimeException("Lock failed! Can't lock file.");
}
try {
$accessInfoText = stream_get_contents($fp);
if ($accessInfoText !== false) {
$accessInfo = json_decode($accessInfoText, true);
}
$result = $fn(isset($accessInfo['time']) ? new Carbon($accessInfo['time']) : null);
$accessInfo = [
'time' => now()->toIso8601String()
];
fseek($fp, 0);
if (fwrite($fp, json_encode($accessInfo)) === false) {
throw new \RuntimeException("I/O Error! Can't write to lock file.");
}
return $result;
} finally {
if (!flock($fp, LOCK_UN)) {
throw new \RuntimeException("Unlock failed! Can't unlock file.");
}
}
} finally {
if (!fclose($fp)) {
throw new \RuntimeException("Unlock failed! Can't close lock file.");
}
}
}
/**
* 指定したメタデータURLのホストが持つrobots.txtをダウンロードします。
* @param string $url メタデータのURL
* @return string
*/
private function fetchRobotsTxt(string $url): ?string
{
$parts = parse_url($url);
$robotsUrl = http_build_url([
'scheme' => $parts['scheme'],
'host' => $parts['host'],
'port' => $parts['port'] ?? null,
'path' => '/robots.txt'
]);
$client = app(Client::class);
try {
$res = $client->get($robotsUrl);
return (string) $res->getBody();
} catch (\Exception $e) {
Log::error("robots.txtの取得に失敗: {$e}");
return null;
}
}
/**
* ContentProviderポリシー情報との照合を行い、アクセス可能かチェックします。アクセスできない場合は例外をスローします。
* @param string $url メタデータを取得したいURL
* @param CarbonInterface|null $lastAccess アクセス先ホストへの最終アクセス日時 (記録がある場合)
* @throws DeniedHostException アクセス先がTissue内のブラックリストに入っている場合にスロー
* @throws DisallowedByProviderException アクセス先のrobots.txtによって拒否されている場合にスロー
*/
private function checkProviderPolicy(string $url, ?CarbonInterface $lastAccess): void
{
DB::beginTransaction();
try {
$hostWithPort = $this->getHostWithPortFromUrl($url);
$contentProvider = ContentProvider::sharedLock()->find($hostWithPort);
if ($contentProvider === null) {
$contentProvider = ContentProvider::create([
'host' => $hostWithPort,
'robots' => $this->fetchRobotsTxt($url),
'robots_cached_at' => now(),
]);
}
if ($contentProvider->is_blocked) {
throw new DeniedHostException($url);
}
// 連続アクセス制限
if ($lastAccess !== null) {
$elapsedSeconds = $lastAccess->diffInSeconds(now(), false);
if ($elapsedSeconds < $contentProvider->access_interval_sec) {
if ($elapsedSeconds < 0) {
$wait = abs($elapsedSeconds) + $contentProvider->access_interval_sec;
} else {
$wait = $contentProvider->access_interval_sec - $elapsedSeconds;
$tagIds = [];
foreach ($resolved->normalizedTags() as $tagName) {
$tag = Tag::firstOrCreate(['name' => $tagName]);
$tagIds[] = $tag->id;
}
sleep($wait);
$metadata->tags()->sync($tagIds);
} catch (TransferException $e) {
// 何らかの通信エラーによってメタデータの取得に失敗した時、とりあえずエラーログにURLを残す
Log::error(self::class . ': メタデータの取得に失敗 URL=' . $url);
throw $e;
}
}
// Fetch robots.txt
if ($contentProvider->robots_cached_at->diffInDays(now()) >= 7) {
$contentProvider->update([
'robots' => $this->fetchRobotsTxt($url),
'robots_cached_at' => now(),
]);
}
// Check robots.txt
$robotsParser = new \RobotsTxtParser($contentProvider->robots);
$robotsParser->setUserAgent('TissueBot');
$robotsDelay = $robotsParser->getDelay();
if ($robotsDelay !== 0 && $robotsDelay >= $contentProvider->access_interval_sec) {
$contentProvider->access_interval_sec = (int) $robotsDelay;
$contentProvider->save();
}
if ($robotsParser->isDisallowed(parse_url($url, PHP_URL_PATH))) {
throw new DisallowedByProviderException($url);
}
DB::commit();
} catch (DeniedHostException | DisallowedByProviderException $e) {
// ContentProviderのデータ更新は行うため
DB::commit();
throw $e;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
/**
* メタデータをリモートサーバに問い合わせて取得します。
* @param string $url メタデータを取得したいURL
* @param Metadata|null $metadata キャッシュ済のメタデータ (存在する場合)
* @return Metadata 取得できたメタデータ
* @throws UncaughtResolverException Resolver内で例外が発生して取得できなかった場合にスロー
* @throws ResolverCircuitBreakException 規定回数以上の解決失敗により、メタデータの取得が不能となっている場合にスロー
*/
private function resolve(string $url, ?Metadata $metadata): Metadata
{
DB::beginTransaction();
try {
if ($metadata === null) {
$metadata = new Metadata(['url' => $url]);
}
if ($metadata->error_count >= self::CIRCUIT_BREAK_COUNT) {
throw new ResolverCircuitBreakException($metadata->error_count, $url);
}
try {
$resolved = $this->resolver->resolve($url);
} catch (\Exception $e) {
$metadata->storeException(now(), $e);
$metadata->save();
throw new UncaughtResolverException(implode(': ', [
$metadata->error_count . '回目のメタデータ取得失敗', get_class($e), $e->getMessage()
]), 0, $e);
}
$metadata->fill([
'title' => $resolved->title,
'description' => $resolved->description,
'image' => $resolved->image,
'expires_at' => $resolved->expires_at
]);
$metadata->clearError();
$metadata->save();
$tagIds = [];
foreach ($resolved->normalizedTags() as $tagName) {
$tag = Tag::firstOrCreate(['name' => $tagName]);
$tagIds[] = $tag->id;
}
$metadata->tags()->sync($tagIds);
DB::commit();
return $metadata;
} catch (UncaughtResolverException $e) {
// Metadataにエラー情報を記録するため
DB::commit();
throw $e;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
});
}
}

View File

@@ -71,9 +71,4 @@ class User extends Authenticatable
{
return $this->hasMany(Like::class);
}
public function checkinWebhooks()
{
return $this->hasMany(CheckinWebhook::class);
}
}