Merge pull request #504 from shikorism/release-20200830
Release 20200830.1400
This commit is contained in:
commit
fd3da94ce8
@ -11,7 +11,7 @@ insert_final_newline = true
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.yml]
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
|
18
.eslintrc.js
vendored
18
.eslintrc.js
vendored
@ -6,25 +6,33 @@ module.exports = {
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/essential',
|
||||
'plugin:react/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
'prettier/@typescript-eslint',
|
||||
'prettier/vue',
|
||||
'prettier/react',
|
||||
],
|
||||
parser: 'vue-eslint-parser',
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 11,
|
||||
parser: '@typescript-eslint/parser',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['prettier', 'vue', '@typescript-eslint', 'jquery'],
|
||||
plugins: ['prettier', 'react', '@typescript-eslint', 'jquery'],
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'jquery/no-ajax': 2,
|
||||
'jquery/no-ajax-events': 2,
|
||||
'react/prop-types': 0,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,6 +6,7 @@
|
||||
/public/storage
|
||||
/public/mix-manifest.json
|
||||
/public/report.html
|
||||
/public/apidoc.html
|
||||
/storage/*.key
|
||||
/vendor
|
||||
/.idea
|
||||
|
39
app/CheckinWebhook.php
Normal file
39
app/CheckinWebhook.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
24
app/ContentProvider.php
Normal file
24
app/ContentProvider.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?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',
|
||||
];
|
||||
}
|
@ -14,11 +14,13 @@ 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'
|
||||
'is_private', 'is_too_sensitive',
|
||||
'checkin_webhook_id'
|
||||
];
|
||||
|
||||
protected $dates = [
|
||||
@ -47,9 +49,9 @@ class Ejaculation extends Model
|
||||
return $this->hasMany(Like::class);
|
||||
}
|
||||
|
||||
public function scopeOnlyWebCheckin(Builder $query)
|
||||
public function scopeVisibleToTimeline(Builder $query)
|
||||
{
|
||||
return $query->where('ejaculations.source', Ejaculation::SOURCE_WEB);
|
||||
return $query->whereIn('ejaculations.source', [Ejaculation::SOURCE_WEB, Ejaculation::SOURCE_WEBHOOK]);
|
||||
}
|
||||
|
||||
public function scopeWithLikes(Builder $query)
|
||||
@ -99,6 +101,7 @@ class Ejaculation extends Model
|
||||
return route('checkin', [
|
||||
'link' => $this->link,
|
||||
'tags' => $this->textTags(),
|
||||
'is_private' => $this->is_private,
|
||||
'is_too_sensitive' => $this->is_too_sensitive,
|
||||
]);
|
||||
}
|
||||
|
@ -4,7 +4,10 @@ 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
|
||||
{
|
||||
@ -20,6 +23,7 @@ class Handler extends ExceptionHandler
|
||||
\Illuminate\Database\Eloquent\ModelNotFoundException::class,
|
||||
\Illuminate\Session\TokenMismatchException::class,
|
||||
\Illuminate\Validation\ValidationException::class,
|
||||
\App\MetadataResolver\ResolverCircuitBreakException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@ -68,4 +72,28 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
103
app/Http/Controllers/Api/WebhookController.php
Normal file
103
app/Http/Controllers/Api/WebhookController.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?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)
|
||||
]);
|
||||
}
|
||||
}
|
@ -16,17 +16,26 @@ class EjaculationController extends Controller
|
||||
{
|
||||
public function create(Request $request)
|
||||
{
|
||||
$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
|
||||
$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
|
||||
];
|
||||
|
||||
return view('ejaculation.checkin')->with('defaults', $defaults);
|
||||
return view('ejaculation.checkin')->with('initialState', $initialState);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
@ -112,13 +121,36 @@ class EjaculationController extends Controller
|
||||
return view('ejaculation.show')->with(compact('user', 'ejaculation', 'ejaculatedSpan'));
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
public function edit(Request $request, $id)
|
||||
{
|
||||
$ejaculation = Ejaculation::findOrFail($id);
|
||||
|
||||
$this->authorize('edit', $ejaculation);
|
||||
|
||||
return view('ejaculation.edit')->with(compact('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->is_private,
|
||||
'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'));
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
|
@ -71,7 +71,7 @@ SQL
|
||||
->select('ejaculations.*')
|
||||
->with('user', 'tags')
|
||||
->withLikes()
|
||||
->onlyWebCheckin()
|
||||
->visibleToTimeline()
|
||||
->take(21)
|
||||
->get();
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\CheckinWebhook;
|
||||
use App\DeactivatedUser;
|
||||
use App\Ejaculation;
|
||||
use App\Exceptions\CsvImportException;
|
||||
@ -75,6 +76,46 @@ 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');
|
||||
|
@ -19,7 +19,7 @@ class TimelineController extends Controller
|
||||
->select('ejaculations.*')
|
||||
->with('user', 'tags')
|
||||
->withLikes()
|
||||
->onlyWebCheckin()
|
||||
->visibleToTimeline()
|
||||
->paginate(21);
|
||||
|
||||
return view('timeline.public')->with(compact('ejaculations'));
|
||||
|
@ -38,15 +38,17 @@ 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,
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
@ -77,6 +79,7 @@ class Kernel extends HttpKernel
|
||||
* @var array
|
||||
*/
|
||||
protected $middlewarePriority = [
|
||||
\App\Http\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\Authenticate::class,
|
||||
|
25
app/Http/Middleware/EnforceJson.php
Normal file
25
app/Http/Middleware/EnforceJson.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
28
app/Http/Resources/EjaculationResource.php
Normal file
28
app/Http/Resources/EjaculationResource.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Metadata extends Model
|
||||
@ -13,10 +15,66 @@ 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'];
|
||||
protected $dates = ['created_at', 'updated_at', 'expires_at', 'error_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 ?? '';
|
||||
}
|
||||
}
|
||||
|
30
app/MetadataResolver/DisallowedByProviderException.php
Normal file
30
app/MetadataResolver/DisallowedByProviderException.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
16
app/MetadataResolver/ResolverCircuitBreakException.php
Normal file
16
app/MetadataResolver/ResolverCircuitBreakException.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
10
app/MetadataResolver/UncaughtResolverException.php
Normal file
10
app/MetadataResolver/UncaughtResolverException.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\MetadataResolver;
|
||||
|
||||
/**
|
||||
* MetadataResolver内で未キャッチの例外が発生した場合にスローされます。
|
||||
*/
|
||||
class UncaughtResolverException extends \RuntimeException
|
||||
{
|
||||
}
|
62
app/Rules/FuzzyBoolean.php
Normal file
62
app/Rules/FuzzyBoolean.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
@ -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,6 +45,8 @@ 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;
|
||||
@ -54,4 +56,9 @@ class CheckinCsvExporter
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static function formatBoolean($value): string
|
||||
{
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ 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;
|
||||
@ -75,6 +76,8 @@ class CheckinCsvImporter
|
||||
'日時' => ['required', new CsvDateTime()],
|
||||
'ノート' => 'nullable|string|max:500',
|
||||
'オカズリンク' => 'nullable|url|max:2000',
|
||||
'非公開' => ['nullable', new FuzzyBoolean()],
|
||||
'センシティブ' => ['nullable', new FuzzyBoolean()],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -88,6 +91,12 @@ 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);
|
||||
|
@ -2,17 +2,26 @@
|
||||
|
||||
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 GuzzleHttp\Exception\TransferException;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use GuzzleHttp\Client;
|
||||
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 */
|
||||
@ -24,6 +33,13 @@ class MetadataResolveService
|
||||
$this->formatter = $formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* メタデータをキャッシュまたはリモートに問い合わせて取得します。
|
||||
* @param string $url メタデータを取得したいURL
|
||||
* @return Metadata 取得できたメタデータ
|
||||
* @throws DeniedHostException アクセス先がブラックリスト入りしているため取得できなかった場合にスロー
|
||||
* @throws UncaughtResolverException Resolver内で例外が発生して取得できなかった場合にスロー
|
||||
*/
|
||||
public function execute(string $url): Metadata
|
||||
{
|
||||
// URLの正規化
|
||||
@ -34,34 +50,252 @@ class MetadataResolveService
|
||||
throw new DeniedHostException($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
|
||||
]);
|
||||
$metadata = Metadata::find($url);
|
||||
|
||||
$tagIds = [];
|
||||
foreach ($resolved->normalizedTags() as $tagName) {
|
||||
$tag = Tag::firstOrCreate(['name' => $tagName]);
|
||||
$tagIds[] = $tag->id;
|
||||
// 無かったら取得
|
||||
// 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;
|
||||
}
|
||||
$metadata->tags()->sync($tagIds);
|
||||
} catch (TransferException $e) {
|
||||
// 何らかの通信エラーによってメタデータの取得に失敗した時、とりあえずエラーログにURLを残す
|
||||
Log::error(self::class . ': メタデータの取得に失敗 URL=' . $url);
|
||||
throw $e;
|
||||
sleep($wait);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -71,4 +71,9 @@ class User extends Authenticatable
|
||||
{
|
||||
return $this->hasMany(Like::class);
|
||||
}
|
||||
|
||||
public function checkinWebhooks()
|
||||
{
|
||||
return $this->hasMany(CheckinWebhook::class);
|
||||
}
|
||||
}
|
||||
|
@ -33,7 +33,8 @@
|
||||
"sentry/sentry-laravel": "1.8.0",
|
||||
"staudenmeir/eloquent-eager-limit": "^1.0",
|
||||
"symfony/css-selector": "^4.3",
|
||||
"symfony/dom-crawler": "^4.3"
|
||||
"symfony/dom-crawler": "^4.3",
|
||||
"t1gor/robots-txt-parser": "^0.2.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-debugbar": "^3.1",
|
||||
|
795
composer.lock
generated
795
composer.lock
generated
File diff suppressed because it is too large
Load Diff
12
database/factories/CheckinWebhookFactory.php
Normal file
12
database/factories/CheckinWebhookFactory.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
/** @var \Illuminate\Database\Eloquent\Factory $factory */
|
||||
|
||||
use App\CheckinWebhook;
|
||||
use Faker\Generator as Faker;
|
||||
|
||||
$factory->define(CheckinWebhook::class, function (Faker $faker) {
|
||||
return [
|
||||
'name' => 'example'
|
||||
];
|
||||
});
|
14
database/factories/ContentProviderFactory.php
Normal file
14
database/factories/ContentProviderFactory.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/** @var \Illuminate\Database\Eloquent\Factory $factory */
|
||||
|
||||
use App\ContentProvider;
|
||||
use Faker\Generator as Faker;
|
||||
|
||||
$factory->define(ContentProvider::class, function (Faker $faker) {
|
||||
return [
|
||||
'host' => 'example.com',
|
||||
'robots' => null,
|
||||
'robots_cached_at' => now(),
|
||||
];
|
||||
});
|
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateCheckinWebhooksTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('checkin_webhooks', function (Blueprint $table) {
|
||||
$table->string('id', 64);
|
||||
$table->integer('user_id')->nullable();
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->primary('id');
|
||||
$table->index('user_id');
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('checkin_webhooks');
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddCheckinWebhookIdToEjaculations extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('ejaculations', function (Blueprint $table) {
|
||||
$table->string('checkin_webhook_id', 64)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('ejaculations', function (Blueprint $table) {
|
||||
$table->dropColumn('checkin_webhook_id');
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddErrorDataToMetadata extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('metadata', function (Blueprint $table) {
|
||||
$table->timestamp('error_at')->nullable();
|
||||
$table->string('error_exception_class')->nullable();
|
||||
$table->integer('error_http_code')->nullable();
|
||||
$table->text('error_body')->nullable();
|
||||
$table->integer('error_count')->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('metadata', function (Blueprint $table) {
|
||||
$table->dropColumn(['error_at', 'error_exception_class', 'error_http_code', 'error_body', 'error_count']);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateContentProvidersTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('content_providers', function (Blueprint $table) {
|
||||
$table->string('host');
|
||||
$table->text('robots')->nullable();
|
||||
$table->timestamp('robots_cached_at');
|
||||
$table->boolean('is_blocked')->default(false);
|
||||
$table->integer('access_interval_sec')->default(5);
|
||||
$table->timestamps();
|
||||
|
||||
$table->primary('host');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('content_providers');
|
||||
}
|
||||
}
|
@ -11,9 +11,9 @@ class EjaculationSourcesSeeder extends Seeder
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
$sources = ['web', 'csv'];
|
||||
$sources = ['web', 'csv', 'webhook'];
|
||||
foreach ($sources as $source) {
|
||||
DB::table('ejaculation_sources')->insert(['name' => $source]);
|
||||
DB::table('ejaculation_sources')->insertOrIgnore(['name' => $source]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
114
openapi.yaml
Normal file
114
openapi.yaml
Normal file
@ -0,0 +1,114 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Tissue API
|
||||
description: |
|
||||
夜のライフログサービス Tissue の公開API仕様です。
|
||||
全てのAPIのURLは `https://shikorism.net/api` から始まります。
|
||||
version: 0.1.0
|
||||
servers:
|
||||
- url: 'https://shikorism.net/api'
|
||||
paths:
|
||||
/webhooks/checkin/{id}:
|
||||
post:
|
||||
summary: /webhooks/checkin/{id}
|
||||
description: Webhook IDを発行したユーザで新規チェックインを行います。
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Webhook管理ページで発行したID
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
checked_in_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: チェックイン日時 (ISO 8601形式、タイムゾーンを省略した場合も受理するが動作は未定義、省略した場合はサーバのシステム日時を使用)
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
maxLength: 255
|
||||
description: タグ (スペースを含めるのは禁止、先頭および末尾に空白が含まれている場合はtrimされる)
|
||||
link:
|
||||
type: string
|
||||
maxLength: 2000
|
||||
description: オカズリンク (http, https)
|
||||
note:
|
||||
type: string
|
||||
maxLength: 500
|
||||
description: ノート
|
||||
is_private:
|
||||
type: boolean
|
||||
default: false
|
||||
description: 非公開チェックインとして設定
|
||||
is_too_sensitive:
|
||||
type: boolean
|
||||
default: false
|
||||
description: チェックイン対象のオカズをより過激なオカズとして設定
|
||||
examples:
|
||||
simple:
|
||||
description: 何も指定しなければ、現在時刻で公開チェックインをおこないます。
|
||||
value: {}
|
||||
complete:
|
||||
value:
|
||||
checked_in_at: 2020-07-21T19:19:19+0900
|
||||
note: すごく出た
|
||||
link: http://example.com
|
||||
tags:
|
||||
- Example
|
||||
- Example_2
|
||||
is_private: false
|
||||
is_too_sensitive: false
|
||||
responses:
|
||||
200:
|
||||
description: チェックイン成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- checkin
|
||||
properties:
|
||||
status:
|
||||
type: number
|
||||
description: HTTPステータスコードと同じ値
|
||||
example: 200
|
||||
checkin:
|
||||
type: object
|
||||
description: チェックインデータ
|
||||
422:
|
||||
description: バリデーションエラー
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- error
|
||||
properties:
|
||||
status:
|
||||
type: number
|
||||
description: HTTPステータスコードと同じ値
|
||||
example: 422
|
||||
error:
|
||||
type: object
|
||||
description: エラーデータ
|
||||
required:
|
||||
- message
|
||||
properties:
|
||||
message:
|
||||
type: object
|
||||
description: エラーの概要
|
||||
example: Validation failed
|
||||
violations:
|
||||
type: string[]
|
||||
description: エラーが発生した各フィールドについてのメッセージ
|
||||
example:
|
||||
- Checkin already exists in this time
|
34
package.json
34
package.json
@ -8,28 +8,35 @@
|
||||
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
|
||||
"prod": "npm run production",
|
||||
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
|
||||
"eslint": "eslint --ext .js,.ts,.vue resources/",
|
||||
"stylelint": "stylelint resources/assets/sass/**/*"
|
||||
"eslint": "eslint --ext .js,.ts,.tsx resources/",
|
||||
"stylelint": "stylelint resources/assets/sass/**/*",
|
||||
"doc": "redoc-cli bundle -o public/apidoc.html openapi.yaml"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^4.5.0",
|
||||
"@types/cal-heatmap": "^3.3.10",
|
||||
"@types/chart.js": "^2.9.22",
|
||||
"@types/chart.js": "^2.9.24",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/clipboard": "^2.0.1",
|
||||
"@types/jquery": "^3.3.38",
|
||||
"@types/js-cookie": "^2.2.0",
|
||||
"@types/qs": "^6.9.4",
|
||||
"@types/react": "^16.9.35",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@typescript-eslint/eslint-plugin": "^3.1.0",
|
||||
"@typescript-eslint/parser": "^3.1.0",
|
||||
"@typescript-eslint/parser": "^3.9.1",
|
||||
"bootstrap": "^4.5.0",
|
||||
"cal-heatmap": "^3.3.10",
|
||||
"chart.js": "^2.7.1",
|
||||
"classnames": "^2.2.6",
|
||||
"clipboard": "^2.0.6",
|
||||
"cross-env": "^5.2.0",
|
||||
"date-fns": "^1.30.1",
|
||||
"date-fns": "^2.15.0",
|
||||
"eslint": "^7.6.0",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint-plugin-jquery": "^1.5.1",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"eslint-plugin-react": "^7.20.6",
|
||||
"husky": "^1.3.1",
|
||||
"jquery": "^3.5.0",
|
||||
"js-cookie": "^2.2.0",
|
||||
@ -39,18 +46,17 @@
|
||||
"open-iconic": "^1.1.1",
|
||||
"popper.js": "^1.14.7",
|
||||
"prettier": "^2.0.5",
|
||||
"redoc-cli": "^0.9.8",
|
||||
"qs": "^6.9.4",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"resolve-url-loader": "^3.1.1",
|
||||
"sass": "^1.26.8",
|
||||
"sass-loader": "^7.1.0",
|
||||
"stylelint": "^9.10.1",
|
||||
"stylelint-config-recess-order": "^2.0.4",
|
||||
"stylelint": "^13.6.1",
|
||||
"stylelint-config-recess-order": "^2.1.0",
|
||||
"ts-loader": "^6.0.1",
|
||||
"typescript": "^3.4.5",
|
||||
"vue": "^2.6.10",
|
||||
"vue-class-component": "^7.1.0",
|
||||
"vue-property-decorator": "^9.0.0",
|
||||
"vue-template-compiler": "^2.6.10"
|
||||
"typescript": "^3.4.5"
|
||||
},
|
||||
"stylelint": {
|
||||
"extends": "stylelint-config-recess-order"
|
||||
@ -65,7 +71,7 @@
|
||||
"stylelint --fix",
|
||||
"git add"
|
||||
],
|
||||
"*.{ts,js,vue}": [
|
||||
"*.{ts,tsx,js}": [
|
||||
"eslint --fix",
|
||||
"git add"
|
||||
],
|
||||
|
@ -1,4 +1,4 @@
|
||||
import * as Cookies from 'js-cookie';
|
||||
import Cookies from 'js-cookie';
|
||||
import { fetchPostJson, fetchDeleteJson, ResponseError } from './fetch';
|
||||
import { linkCard, pageSelector, deleteCheckinModal } from './tissue';
|
||||
|
||||
|
@ -1,68 +0,0 @@
|
||||
import Vue from 'vue';
|
||||
import TagInput from './components/TagInput.vue';
|
||||
import MetadataPreview from './components/MetadataPreview.vue';
|
||||
import { fetchGet, ResponseError } from './fetch';
|
||||
|
||||
export const bus = new Vue({ name: 'EventBus' });
|
||||
|
||||
export enum MetadataLoadState {
|
||||
Inactive,
|
||||
Loading,
|
||||
Success,
|
||||
Failed,
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
metadata: null,
|
||||
metadataLoadState: MetadataLoadState.Inactive,
|
||||
},
|
||||
components: {
|
||||
TagInput,
|
||||
MetadataPreview,
|
||||
},
|
||||
mounted() {
|
||||
// オカズリンクにURLがセットされている場合は、すぐにメタデータを取得する
|
||||
const linkInput = this.$el.querySelector<HTMLInputElement>('#link');
|
||||
if (linkInput && /^https?:\/\//.test(linkInput.value)) {
|
||||
this.fetchMetadata(linkInput.value);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// オカズリンクの変更時
|
||||
onChangeLink(event: Event) {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
const url = event.target.value;
|
||||
|
||||
if (url.trim() === '' || !/^https?:\/\//.test(url)) {
|
||||
this.metadata = null;
|
||||
this.metadataLoadState = MetadataLoadState.Inactive;
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchMetadata(url);
|
||||
}
|
||||
},
|
||||
// メタデータの取得
|
||||
fetchMetadata(url: string) {
|
||||
this.metadataLoadState = MetadataLoadState.Loading;
|
||||
|
||||
fetchGet('/api/checkin/card', { url })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
this.metadata = data;
|
||||
this.metadataLoadState = MetadataLoadState.Success;
|
||||
})
|
||||
.catch(() => {
|
||||
this.metadata = null;
|
||||
this.metadataLoadState = MetadataLoadState.Failed;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
6
resources/assets/js/checkin.tsx
Normal file
6
resources/assets/js/checkin.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { CheckinForm } from './components/CheckinForm';
|
||||
|
||||
const initialState = JSON.parse(document.getElementById('initialState')?.textContent as string);
|
||||
ReactDOM.render(<CheckinForm initialState={initialState} />, document.getElementById('checkinForm'));
|
25
resources/assets/js/components/CheckBox.tsx
Normal file
25
resources/assets/js/components/CheckBox.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
type CheckboxProps = {
|
||||
id: string;
|
||||
name: string;
|
||||
className?: string;
|
||||
checked?: boolean;
|
||||
onChange?: (newValue: boolean) => void;
|
||||
};
|
||||
|
||||
export const CheckBox: React.FC<CheckboxProps> = ({ id, name, className, checked, onChange, children }) => (
|
||||
<div className={`custom-control custom-checkbox ${className}`}>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
className="custom-control-input"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange && onChange(e.target.checked)}
|
||||
/>
|
||||
<label className="custom-control-label" htmlFor={id}>
|
||||
{children}
|
||||
</label>
|
||||
</div>
|
||||
);
|
149
resources/assets/js/components/CheckinForm.tsx
Normal file
149
resources/assets/js/components/CheckinForm.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React, { useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { CheckBox } from './CheckBox';
|
||||
import { FieldError, StandaloneFieldError } from './FieldError';
|
||||
import { TagInput } from './TagInput';
|
||||
import { MetadataPreview } from './MetadataPreview';
|
||||
|
||||
type CheckinFormProps = {
|
||||
initialState: any;
|
||||
};
|
||||
|
||||
export const CheckinForm: React.FC<CheckinFormProps> = ({ initialState }) => {
|
||||
const [date, setDate] = useState<string>(initialState.fields.date || '');
|
||||
const [time, setTime] = useState<string>(initialState.fields.time || '');
|
||||
const [tags, setTags] = useState<string[]>(initialState.fields.tags || []);
|
||||
const [link, setLink] = useState<string>(initialState.fields.link || '');
|
||||
const [linkForPreview, setLinkForPreview] = useState(link);
|
||||
const [note, setNote] = useState<string>(initialState.fields.note || '');
|
||||
const [isPrivate, setPrivate] = useState<boolean>(!!initialState.fields.is_private);
|
||||
const [isTooSensitive, setTooSensitive] = useState<boolean>(!!initialState.fields.is_too_sensitive);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<div className="form-group col-sm-6">
|
||||
<label htmlFor="date">
|
||||
<span className="oi oi-calendar" /> 日付
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="date"
|
||||
name="date"
|
||||
className={classNames({
|
||||
'form-control': true,
|
||||
'is-invalid': initialState.errors?.date || initialState.errors?.datetime,
|
||||
})}
|
||||
pattern="^20[0-9]{2}/(0[1-9]|1[0-2])/(0[1-9]|[12][0-9]|3[01])$"
|
||||
required
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={initialState.errors?.date} />
|
||||
</div>
|
||||
<div className="form-group col-sm-6">
|
||||
<label htmlFor="time">
|
||||
<span className="oi oi-clock" /> 時刻
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="time"
|
||||
name="time"
|
||||
className={classNames({
|
||||
'form-control': true,
|
||||
'is-invalid': initialState.errors?.time || initialState.errors?.datetime,
|
||||
})}
|
||||
pattern="^([01][0-9]|2[0-3]):[0-5][0-9]$"
|
||||
required
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={initialState.errors?.time} />
|
||||
</div>
|
||||
<StandaloneFieldError errors={initialState.errors?.datetime} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group col-sm-12">
|
||||
<label htmlFor="tagInput">
|
||||
<span className="oi oi-tags" /> タグ
|
||||
</label>
|
||||
<TagInput
|
||||
id="tagInput"
|
||||
name="tags"
|
||||
values={tags}
|
||||
isInvalid={!!initialState.errors?.tags}
|
||||
onChange={(v) => setTags(v)}
|
||||
/>
|
||||
<small className="form-text text-muted">Tab, Enter, 半角スペースのいずれかで入力確定します。</small>
|
||||
<FieldError errors={initialState.errors?.tags} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group col-sm-12">
|
||||
<label htmlFor="link">
|
||||
<span className="oi oi-link-intact" /> オカズリンク
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="link"
|
||||
name="link"
|
||||
autoComplete="off"
|
||||
className={classNames({ 'form-control': true, 'is-invalid': initialState.errors?.link })}
|
||||
placeholder="http://..."
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
onBlur={() => setLinkForPreview(link)}
|
||||
/>
|
||||
<small className="form-text text-muted">オカズのURLを貼り付けて登録することができます。</small>
|
||||
<FieldError errors={initialState.errors?.link} />
|
||||
</div>
|
||||
</div>
|
||||
<MetadataPreview link={linkForPreview} tags={tags} onClickTag={(v) => setTags(tags.concat(v))} />
|
||||
<div className="form-row">
|
||||
<div className="form-group col-sm-12">
|
||||
<label htmlFor="note">
|
||||
<span className="oi oi-comment-square" /> ノート
|
||||
</label>
|
||||
<textarea
|
||||
id="note"
|
||||
name="note"
|
||||
className={classNames({ 'form-control': true, 'is-invalid': initialState.errors?.note })}
|
||||
rows={4}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
<small className="form-text text-muted">最大 500 文字</small>
|
||||
<FieldError errors={initialState.errors?.note} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row mt-4">
|
||||
<p>オプション</p>
|
||||
<div className="form-group col-sm-12">
|
||||
<CheckBox
|
||||
id="isPrivate"
|
||||
name="is_private"
|
||||
className="mb-3"
|
||||
checked={isPrivate}
|
||||
onChange={(v) => setPrivate(v)}
|
||||
>
|
||||
<span className="oi oi-lock-locked" /> このチェックインを非公開にする
|
||||
</CheckBox>
|
||||
<CheckBox
|
||||
id="isTooSensitive"
|
||||
name="is_too_sensitive"
|
||||
className="mb-3"
|
||||
checked={isTooSensitive}
|
||||
onChange={(v) => setTooSensitive(v)}
|
||||
>
|
||||
<span className="oi oi-warning" /> チェックイン対象のオカズをより過激なオカズとして設定する
|
||||
</CheckBox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<button className="btn btn-primary" type="submit">
|
||||
チェックイン
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
16
resources/assets/js/components/FieldError.tsx
Normal file
16
resources/assets/js/components/FieldError.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
type FieldErrorProps = {
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
export const FieldError: React.FC<FieldErrorProps> = ({ errors }) =>
|
||||
(errors && errors.length > 0 && <div className="invalid-feedback">{errors[0]}</div>) || null;
|
||||
|
||||
export const StandaloneFieldError: React.FC<FieldErrorProps> = ({ errors }) =>
|
||||
(errors && errors.length > 0 && (
|
||||
<div className="form-group col-sm-12" style={{ marginTop: '-1rem' }}>
|
||||
<small className="text-danger">{errors[0]}</small>
|
||||
</div>
|
||||
)) ||
|
||||
null;
|
157
resources/assets/js/components/MetadataPreview.tsx
Normal file
157
resources/assets/js/components/MetadataPreview.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { fetchGet, ResponseError } from '../fetch';
|
||||
|
||||
enum MetadataLoadState {
|
||||
Inactive,
|
||||
Loading,
|
||||
Success,
|
||||
Failed,
|
||||
}
|
||||
|
||||
type Metadata = {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
expires_at: string | null;
|
||||
tags: {
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type Suggestion = {
|
||||
name: string;
|
||||
used: boolean;
|
||||
};
|
||||
|
||||
type MetadataPreviewProps = {
|
||||
link: string;
|
||||
tags: string[];
|
||||
onClickTag: (tag: string) => void;
|
||||
};
|
||||
|
||||
const MetadataLoading = () => (
|
||||
<div className="row no-gutters">
|
||||
<div className="col-12">
|
||||
<div className="card-body">
|
||||
<h6 className="card-title text-center font-weight-bold text-info">
|
||||
<span className="oi oi-loop-circular" /> オカズの情報を読み込んでいます…
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MetadataLoadFailed = () => (
|
||||
<div className="row no-gutters">
|
||||
<div className="col-12">
|
||||
<div className="card-body">
|
||||
<h6 className="card-title text-center font-weight-bold text-danger">
|
||||
<span className="oi oi-circle-x" /> オカズの情報を読み込めませんでした
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MetadataPreview: React.FC<MetadataPreviewProps> = ({ link, tags, onClickTag }) => {
|
||||
const [state, setState] = useState(MetadataLoadState.Inactive);
|
||||
const [metadata, setMetadata] = useState<Metadata | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (link.trim() === '' || !/^https?:\/\//.test(link)) {
|
||||
setState(MetadataLoadState.Inactive);
|
||||
setMetadata(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(MetadataLoadState.Loading);
|
||||
fetchGet('/api/checkin/card', { url: link })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setState(MetadataLoadState.Success);
|
||||
setMetadata(data);
|
||||
})
|
||||
.catch(() => {
|
||||
setState(MetadataLoadState.Failed);
|
||||
setMetadata(null);
|
||||
});
|
||||
}, [link]);
|
||||
|
||||
if (state === MetadataLoadState.Inactive) {
|
||||
return null;
|
||||
}
|
||||
const hasImage = metadata !== null && metadata.image !== '';
|
||||
const descClasses = classNames({
|
||||
'col-8': hasImage,
|
||||
'col-12': !hasImage,
|
||||
});
|
||||
const tagClasses = (s: Suggestion) =>
|
||||
classNames({
|
||||
'list-inline-item': true,
|
||||
badge: true,
|
||||
'badge-primary': !s.used,
|
||||
'badge-secondary': s.used,
|
||||
'metadata-tag-item': true,
|
||||
});
|
||||
const suggestions =
|
||||
metadata?.tags.map((t) => ({
|
||||
name: t.name,
|
||||
used: tags.indexOf(t.name) !== -1,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<div className="form-row">
|
||||
<div className="form-group col-sm-12">
|
||||
<div className="card tis-metadata-preview-link-card mb-2 px-0">
|
||||
{state === MetadataLoadState.Loading ? (
|
||||
<MetadataLoading />
|
||||
) : state === MetadataLoadState.Success ? (
|
||||
<div className="row no-gutters">
|
||||
{hasImage && (
|
||||
<div className="col-4 justify-content-center align-items-center">
|
||||
<img src={metadata?.image} alt="Thumbnail" className="w-100 bg-secondary" />
|
||||
</div>
|
||||
)}
|
||||
<div className={descClasses}>
|
||||
<div className="card-body">
|
||||
<h6 className="card-title font-weight-bold">{metadata?.title}</h6>
|
||||
{suggestions.length > 0 && (
|
||||
<>
|
||||
<p className="card-text mb-2">
|
||||
タグ候補
|
||||
<br />
|
||||
<span className="text-secondary">
|
||||
(クリックするとタグ入力欄にコピーできます)
|
||||
</span>
|
||||
</p>
|
||||
<ul className="list-inline d-inline">
|
||||
{suggestions.map((tag) => (
|
||||
<li
|
||||
key={tag.name}
|
||||
className={tagClasses(tag)}
|
||||
onClick={() => onClickTag(tag.name)}
|
||||
>
|
||||
<span className="oi oi-tag" /> {tag.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MetadataLoadFailed />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,156 +0,0 @@
|
||||
<template>
|
||||
<div class="form-row" v-if="state !== MetadataLoadState.Inactive">
|
||||
<div class="form-group col-sm-12">
|
||||
<div class="card link-card-mini mb-2 px-0">
|
||||
<div v-if="state === MetadataLoadState.Loading" class="row no-gutters">
|
||||
<div class="col-12">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-center font-weight-bold text-info" style="font-size: small;">
|
||||
<span class="oi oi-loop-circular"></span> オカズの情報を読み込んでいます…
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="state === MetadataLoadState.Success" class="row no-gutters">
|
||||
<div v-if="hasImage" class="col-4 justify-content-center align-items-center">
|
||||
<img :src="metadata.image" alt="Thumbnail" class="w-100 bg-secondary" />
|
||||
</div>
|
||||
<div :class="descClasses">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title font-weight-bold" style="font-size: small;">{{ metadata.title }}</h6>
|
||||
<template v-if="suggestions.length > 0">
|
||||
<p class="card-text mb-2" style="font-size: small;">
|
||||
タグ候補<br /><span class="text-secondary"
|
||||
>(クリックするとタグ入力欄にコピーできます)</span
|
||||
>
|
||||
</p>
|
||||
<ul class="list-inline d-inline">
|
||||
<li
|
||||
v-for="tag in suggestions"
|
||||
:class="tagClasses(tag)"
|
||||
@click="addTag(tag.name)"
|
||||
:key="tag.name"
|
||||
>
|
||||
<span class="oi oi-tag"></span> {{ tag.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="row no-gutters">
|
||||
<div class="col-12">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-center font-weight-bold text-danger" style="font-size: small;">
|
||||
<span class="oi oi-circle-x"></span> オカズの情報を読み込めませんでした
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from 'vue-property-decorator';
|
||||
import { bus, MetadataLoadState } from '../checkin';
|
||||
|
||||
type Metadata = {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
expires_at: string | null;
|
||||
tags: {
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type Suggestion = {
|
||||
name: string;
|
||||
used: boolean;
|
||||
};
|
||||
|
||||
@Component
|
||||
export default class MetadataPreview extends Vue {
|
||||
@Prop() readonly state!: MetadataLoadState;
|
||||
@Prop() readonly metadata!: Metadata | null;
|
||||
|
||||
// for use in v-if
|
||||
private readonly MetadataLoadState = MetadataLoadState;
|
||||
|
||||
tags: string[] = [];
|
||||
|
||||
created(): void {
|
||||
bus.$on('change-tag', (tags: string[]) => (this.tags = tags));
|
||||
bus.$emit('resend-tag');
|
||||
}
|
||||
|
||||
addTag(tag: string): void {
|
||||
bus.$emit('add-tag', tag);
|
||||
}
|
||||
|
||||
tagClasses(s: Suggestion) {
|
||||
return {
|
||||
'list-inline-item': true,
|
||||
badge: true,
|
||||
'badge-primary': !s.used,
|
||||
'badge-secondary': s.used,
|
||||
'metadata-tag-item': true,
|
||||
};
|
||||
}
|
||||
|
||||
get suggestions(): Suggestion[] {
|
||||
if (this.metadata === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.metadata.tags.map((t) => {
|
||||
return {
|
||||
name: t.name,
|
||||
used: this.tags.indexOf(t.name) !== -1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get hasImage() {
|
||||
return this.metadata !== null && this.metadata.image !== '';
|
||||
}
|
||||
|
||||
get descClasses() {
|
||||
return {
|
||||
'col-8': this.hasImage,
|
||||
'col-12': !this.hasImage,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.link-card-mini {
|
||||
$height: 150px;
|
||||
overflow: hidden;
|
||||
|
||||
.row > div:first-child {
|
||||
display: flex;
|
||||
|
||||
&:not([display='none']) {
|
||||
min-height: $height;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-tag-item {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
75
resources/assets/js/components/TagInput.tsx
Normal file
75
resources/assets/js/components/TagInput.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
type TagInputProps = {
|
||||
id: string;
|
||||
name: string;
|
||||
values: string[];
|
||||
isInvalid: boolean;
|
||||
onChange?: (newValues: string[]) => void;
|
||||
};
|
||||
|
||||
export const TagInput: React.FC<TagInputProps> = ({ id, name, values, isInvalid, onChange }) => {
|
||||
const [buffer, setBuffer] = useState('');
|
||||
const containerClass = classNames('form-control', 'h-auto', { 'is-invalid': isInvalid });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const removeTag = (index: number) => {
|
||||
onChange && onChange(values.filter((v, i) => i != index));
|
||||
};
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (buffer.trim() !== '') {
|
||||
switch (event.key) {
|
||||
case 'Tab':
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if ((event as any).isComposing !== true) {
|
||||
onChange && onChange(values.concat(buffer.trim().replace(/\s+/g, '_')));
|
||||
setBuffer('');
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'Unidentified': {
|
||||
// 実際にテキストボックスに入力されている文字を見に行く (フォールバック処理)
|
||||
const nativeEvent = event.nativeEvent;
|
||||
if (nativeEvent.srcElement && (nativeEvent.srcElement as HTMLInputElement).value.slice(-1) == ' ') {
|
||||
onChange && onChange(values.concat(buffer.trim().replace(/\s+/g, '_')));
|
||||
setBuffer('');
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (event.key === 'Enter') {
|
||||
// 誤爆防止
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClass} onClick={() => inputRef.current?.focus()}>
|
||||
<input name={name} type="hidden" value={values.join(' ')} />
|
||||
<ul className="list-inline d-inline">
|
||||
{values.map((tag, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={classNames('list-inline-item', 'badge', 'badge-primary', 'tis-tag-input-item')}
|
||||
onClick={() => removeTag(i)}
|
||||
>
|
||||
<span className="oi oi-tag" /> {tag} | x
|
||||
</li>
|
||||
))}
|
||||
<li className="list-inline-item">
|
||||
<input
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="tis-tag-input-field"
|
||||
value={buffer}
|
||||
onChange={(e) => setBuffer(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,96 +0,0 @@
|
||||
<template>
|
||||
<div :class="containerClass" @click="$refs.input.focus()">
|
||||
<input :name="name" type="hidden" :value="tagValue" />
|
||||
<ul class="list-inline d-inline">
|
||||
<li
|
||||
v-for="(tag, i) in tags"
|
||||
class="list-inline-item badge badge-primary tag-item"
|
||||
@click="removeTag(i)"
|
||||
:key="tag"
|
||||
>
|
||||
<span class="oi oi-tag"></span> {{ tag }} | x
|
||||
</li>
|
||||
</ul>
|
||||
<input :id="id" ref="input" type="text" class="tag-input" v-model="buffer" @keydown="onKeyDown" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop, Watch } from 'vue-property-decorator';
|
||||
import { bus } from '../checkin';
|
||||
|
||||
@Component
|
||||
export default class TagInput extends Vue {
|
||||
@Prop(String) readonly id!: string;
|
||||
@Prop(String) readonly name!: string;
|
||||
@Prop(String) readonly value!: string;
|
||||
@Prop(Boolean) readonly isInvalid!: boolean;
|
||||
|
||||
tags: string[] = this.value.trim() !== '' ? this.value.trim().split(' ') : [];
|
||||
buffer = '';
|
||||
|
||||
created() {
|
||||
bus.$on('add-tag', (tag: string) => this.tags.indexOf(tag) === -1 && this.tags.push(tag));
|
||||
bus.$on('resend-tag', () => bus.$emit('change-tag', this.tags));
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
if (this.buffer.trim() !== '') {
|
||||
switch (event.key) {
|
||||
case 'Tab':
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if ((event as any).isComposing !== true) {
|
||||
this.tags.push(this.buffer.trim().replace(/\s+/g, '_'));
|
||||
this.buffer = '';
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'Unidentified':
|
||||
// 実際にテキストボックスに入力されている文字を見に行く (フォールバック処理)
|
||||
if (event.srcElement && (event.srcElement as HTMLInputElement).value.slice(-1) == ' ') {
|
||||
this.tags.push(this.buffer.trim().replace(/\s+/g, '_'));
|
||||
this.buffer = '';
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else if (event.key === 'Enter') {
|
||||
// 誤爆防止
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(index: number) {
|
||||
this.tags.splice(index, 1);
|
||||
}
|
||||
|
||||
@Watch('tags')
|
||||
onTagsChanged() {
|
||||
bus.$emit('change-tag', this.tags);
|
||||
}
|
||||
|
||||
get containerClass() {
|
||||
return {
|
||||
'form-control': true,
|
||||
'h-auto': true,
|
||||
'is-invalid': this.isInvalid,
|
||||
};
|
||||
}
|
||||
|
||||
get tagValue(): string {
|
||||
return this.tags.join(' ');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-input {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
</style>
|
@ -67,5 +67,6 @@ export class ResponseError extends Error {
|
||||
|
||||
this.name = 'ResponseError';
|
||||
this.response = response;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import * as Chart from 'chart.js';
|
||||
import Chart from 'chart.js';
|
||||
|
||||
const graph = document.getElementById('global-count-graph') as HTMLCanvasElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
32
resources/assets/js/setting/webhooks.ts
Normal file
32
resources/assets/js/setting/webhooks.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import ClipboardJS from 'clipboard';
|
||||
|
||||
$('.webhook-url').on('focus', function () {
|
||||
$(this).trigger('select');
|
||||
});
|
||||
|
||||
new ClipboardJS('.copy-to-clipboard', {
|
||||
target(elem: Element): Element {
|
||||
return elem.parentElement?.parentElement?.querySelector('.webhook-url') as Element;
|
||||
},
|
||||
}).on('success', (e) => {
|
||||
e.clearSelection();
|
||||
$(e.trigger).popover('show');
|
||||
});
|
||||
$('.copy-to-clipboard').on('shown.bs.popover', function () {
|
||||
setTimeout(() => $(this).popover('hide'), 3000);
|
||||
});
|
||||
|
||||
const deleteModal = document.getElementById('deleteIncomingWebhookModal');
|
||||
if (deleteModal) {
|
||||
let id: any = null;
|
||||
deleteModal.querySelector('form')?.addEventListener('submit', function () {
|
||||
this.action = this.action.replace('@', id);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>('[data-target="#deleteIncomingWebhookModal"]').forEach((el) => {
|
||||
el.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
id = this.dataset.id;
|
||||
$(deleteModal).modal('show', this);
|
||||
});
|
||||
});
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { fetchGet } from './fetch';
|
||||
import { fetchGet, ResponseError } from './fetch';
|
||||
|
||||
export function suicide<T>(e: T) {
|
||||
return function (): never {
|
||||
@ -15,7 +15,12 @@ export function linkCard(el: Element) {
|
||||
}
|
||||
|
||||
fetchGet('/api/checkin/card', { url })
|
||||
.then((response) => response.json())
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw new ResponseError(response);
|
||||
})
|
||||
.then((data) => {
|
||||
const metaColumn = el.querySelector('.col-12:last-of-type') || die();
|
||||
const imageColumn = el.querySelector<HTMLElement>('.col-12:first-of-type') || die();
|
||||
|
@ -1,4 +1,4 @@
|
||||
import * as CalHeatMap from 'cal-heatmap';
|
||||
import CalHeatMap from 'cal-heatmap';
|
||||
import { subMonths } from 'date-fns';
|
||||
|
||||
if (document.getElementById('cal-heatmap')) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import * as CalHeatMap from 'cal-heatmap';
|
||||
import * as Chart from 'chart.js';
|
||||
import CalHeatMap from 'cal-heatmap';
|
||||
import Chart from 'chart.js';
|
||||
import { addMonths, format } from 'date-fns';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@ -90,7 +90,7 @@ function createMonthlyGraphData(from: Date) {
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const current = addMonths(from, i);
|
||||
const yearAndMonth = format(current, 'YYYY/MM');
|
||||
const yearAndMonth = format(current, 'yyyy/MM');
|
||||
keys.push(yearAndMonth);
|
||||
values.push(graphData.monthlySum[yearAndMonth] || 0);
|
||||
}
|
||||
|
2
resources/assets/sass/app.scss
vendored
2
resources/assets/sass/app.scss
vendored
@ -13,6 +13,8 @@ $primary: #e53fb1;
|
||||
// Components
|
||||
@import "components/ejaculation";
|
||||
@import "components/link-card";
|
||||
@import "components/tag-input";
|
||||
@import "components/metadata-preview";
|
||||
|
||||
// Tag
|
||||
@import "tag/index";
|
||||
|
32
resources/assets/sass/components/_metadata-preview.scss
vendored
Normal file
32
resources/assets/sass/components/_metadata-preview.scss
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
.tis-metadata-preview {
|
||||
&-link-card {
|
||||
$height: 150px;
|
||||
overflow: hidden;
|
||||
|
||||
.row > div:first-child {
|
||||
display: flex;
|
||||
|
||||
&:not([display='none']) {
|
||||
min-height: $height;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: small;
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
&-tag-item {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
11
resources/assets/sass/components/_tag-input.scss
vendored
Normal file
11
resources/assets/sass/components/_tag-input.scss
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
.tis-tag-input {
|
||||
&-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&-field {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
@ -6,14 +6,19 @@
|
||||
</h5>
|
||||
</div>
|
||||
<!-- tags -->
|
||||
@if ($ejaculation->is_private || $ejaculation->source === 'csv' || $ejaculation->tags->isNotEmpty())
|
||||
@if ($ejaculation->is_private || $ejaculation->source !== 'web' || $ejaculation->tags->isNotEmpty())
|
||||
<p class="mb-2">
|
||||
@if ($ejaculation->is_private)
|
||||
<span class="badge badge-warning"><span class="oi oi-lock-locked"></span> 非公開</span>
|
||||
@endif
|
||||
@if ($ejaculation->source === 'csv')
|
||||
<span class="badge badge-info"><span class="oi oi-cloud-upload"></span> インポート</span>
|
||||
@endif
|
||||
@switch ($ejaculation->source)
|
||||
@case ('csv')
|
||||
<span class="badge badge-info"><span class="oi oi-cloud-upload"></span> インポート</span>
|
||||
@break
|
||||
@case ('webhook')
|
||||
<span class="badge badge-info" data-toggle="tooltip" title="Webhookからチェックイン"><span class="oi oi-flash"></span></span>
|
||||
@break
|
||||
@endswitch
|
||||
@foreach ($ejaculation->tags as $tag)
|
||||
<a class="badge badge-secondary" href="{{ route('search', ['q' => $tag->name]) }}"><span class="oi oi-tag"></span> {{ $tag->name }}</a>
|
||||
@endforeach
|
||||
|
@ -3,99 +3,15 @@
|
||||
@section('title', 'チェックイン')
|
||||
|
||||
@section('content')
|
||||
<div id="app" class="container">
|
||||
<div class="container">
|
||||
<h2>今致してる?</h2>
|
||||
<hr>
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-lg-6">
|
||||
<form method="post" action="{{ route('checkin') }}">
|
||||
{{ csrf_field() }}
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-6">
|
||||
<label for="date"><span class="oi oi-calendar"></span> 日付</label>
|
||||
<input id="date" name="date" type="text" class="form-control {{ $errors->has('date') || $errors->has('datetime') ? ' is-invalid' : '' }}"
|
||||
pattern="^20[0-9]{2}/(0[1-9]|1[0-2])/(0[1-9]|[12][0-9]|3[01])$" value="{{ old('date') ?? $defaults['date'] }}" required>
|
||||
|
||||
@if ($errors->has('date'))
|
||||
<div class="invalid-feedback">{{ $errors->first('date') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="form-group col-sm-6">
|
||||
<label for="time"><span class="oi oi-clock"></span> 時刻</label>
|
||||
<input id="time" name="time" type="text" class="form-control {{ $errors->has('time') || $errors->has('datetime') ? ' is-invalid' : '' }}"
|
||||
pattern="^([01][0-9]|2[0-3]):[0-5][0-9]$" value="{{ old('time') ?? $defaults['time'] }}" required>
|
||||
|
||||
@if ($errors->has('time'))
|
||||
<div class="invalid-feedback">{{ $errors->first('time') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@if ($errors->has('datetime'))
|
||||
<div class="form-group col-sm-12" style="margin-top: -1rem;">
|
||||
<small class="text-danger">{{ $errors->first('datetime') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-12">
|
||||
<label for="tagInput"><span class="oi oi-tags"></span> タグ</label>
|
||||
<tag-input id="tagInput" name="tags" value="{{ old('tags') ?? $defaults['tags'] }}" :is-invalid="{{ $errors->has('tags') ? 'true' : 'false' }}"></tag-input>
|
||||
<small class="form-text text-muted">
|
||||
Tab, Enter, 半角スペースのいずれかで入力確定します。
|
||||
</small>
|
||||
|
||||
@if ($errors->has('tags'))
|
||||
<div class="invalid-feedback">{{ $errors->first('tags') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-12">
|
||||
<label for="link"><span class="oi oi-link-intact"></span> オカズリンク</label>
|
||||
<input id="link" name="link" type="text" autocomplete="off" class="form-control {{ $errors->has('link') ? ' is-invalid' : '' }}"
|
||||
placeholder="http://..." value="{{ old('link') ?? $defaults['link'] }}"
|
||||
@change="onChangeLink">
|
||||
<small class="form-text text-muted">
|
||||
オカズのURLを貼り付けて登録することができます。
|
||||
</small>
|
||||
@if ($errors->has('link'))
|
||||
<div class="invalid-feedback">{{ $errors->first('link') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<metadata-preview :metadata="metadata" :state="metadataLoadState"></metadata-preview>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-12">
|
||||
<label for="note"><span class="oi oi-comment-square"></span> ノート</label>
|
||||
<textarea id="note" name="note" class="form-control {{ $errors->has('note') ? ' is-invalid' : '' }}" rows="4">{{ old('note') ?? $defaults['note'] }}</textarea>
|
||||
<small class="form-text text-muted">
|
||||
最大 500 文字
|
||||
</small>
|
||||
@if ($errors->has('note'))
|
||||
<div class="invalid-feedback">{{ $errors->first('note') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row mt-4">
|
||||
<p>オプション</p>
|
||||
<div class="form-group col-sm-12">
|
||||
<div class="custom-control custom-checkbox mb-3">
|
||||
<input id="isPrivate" name="is_private" type="checkbox" class="custom-control-input" {{ old('is_private') || $defaults['is_private'] ? 'checked' : '' }}>
|
||||
<label class="custom-control-label" for="isPrivate">
|
||||
<span class="oi oi-lock-locked"></span> このチェックインを非公開にする
|
||||
</label>
|
||||
</div>
|
||||
<div class="custom-control custom-checkbox mb-3">
|
||||
<input id="isTooSensitive" name="is_too_sensitive" type="checkbox" class="custom-control-input" {{ old('is_too_sensitive') || $defaults['is_too_sensitive'] ? 'checked' : '' }}>
|
||||
<label class="custom-control-label" for="isTooSensitive">
|
||||
<span class="oi oi-warning"></span> チェックイン対象のオカズをより過激なオカズとして設定する
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button class="btn btn-primary" type="submit">チェックイン</button>
|
||||
<div id="checkinForm">
|
||||
<div class="text-center small" style="height: 640px;">しばらくお待ちください…</div>
|
||||
</div>
|
||||
</form>
|
||||
<p class="text-center small mt-4"><strong>Tips</strong>: ブックマークレットや共有機能で、簡単にチェックインできます! <a href="{{ route('checkin.tools') }}" target="_blank" rel="noopener">使い方はこちら</a></p>
|
||||
@ -105,5 +21,6 @@
|
||||
@endsection
|
||||
|
||||
@push('script')
|
||||
<script id="initialState" type="application/json">@json($initialState)</script>
|
||||
<script src="{{ mix('js/checkin.js') }}"></script>
|
||||
@endpush
|
||||
|
@ -3,7 +3,7 @@
|
||||
@section('title', 'チェックインの修正')
|
||||
|
||||
@section('content')
|
||||
<div id="app" class="container">
|
||||
<div class="container">
|
||||
<h2>チェックインの修正</h2>
|
||||
<hr>
|
||||
<div class="row justify-content-center mt-5">
|
||||
@ -11,92 +11,8 @@
|
||||
<form method="post" action="{{ route('checkin.update', ['id' => $ejaculation->id]) }}">
|
||||
{{ method_field('PUT') }}
|
||||
{{ csrf_field() }}
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-6">
|
||||
<label for="date"><span class="oi oi-calendar"></span> 日付</label>
|
||||
<input id="date" name="date" type="text" class="form-control {{ $errors->has('date') || $errors->has('datetime') ? ' is-invalid' : '' }}"
|
||||
pattern="^20[0-9]{2}/(0[1-9]|1[0-2])/(0[1-9]|[12][0-9]|3[01])$" value="{{ old('date') ?? $ejaculation->ejaculated_date->format('Y/m/d') }}" required>
|
||||
|
||||
@if ($errors->has('date'))
|
||||
<div class="invalid-feedback">{{ $errors->first('date') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="form-group col-sm-6">
|
||||
<label for="time"><span class="oi oi-clock"></span> 時刻</label>
|
||||
<input id="time" name="time" type="text" class="form-control {{ $errors->has('time') || $errors->has('datetime') ? ' is-invalid' : '' }}"
|
||||
pattern="^([01][0-9]|2[0-3]):[0-5][0-9]$" value="{{ old('time') ?? $ejaculation->ejaculated_date->format('H:i') }}" required>
|
||||
|
||||
@if ($errors->has('time'))
|
||||
<div class="invalid-feedback">{{ $errors->first('time') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@if ($errors->has('datetime'))
|
||||
<div class="form-group col-sm-12" style="margin-top: -1rem;">
|
||||
<small class="text-danger">{{ $errors->first('datetime') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-12">
|
||||
<label for="tagInput"><span class="oi oi-tags"></span> タグ</label>
|
||||
<tag-input id="tagInput" name="tags" value="{{ old('tags') ?? $ejaculation->textTags() }}" :is-invalid="{{ $errors->has('tags') ? 'true' : 'false' }}"></tag-input>
|
||||
<small class="form-text text-muted">
|
||||
Tab, Enter, 半角スペースのいずれかで入力確定します。
|
||||
</small>
|
||||
|
||||
@if ($errors->has('tags'))
|
||||
<div class="invalid-feedback">{{ $errors->first('tags') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-12">
|
||||
<label for="link"><span class="oi oi-link-intact"></span> オカズリンク</label>
|
||||
<input id="link" name="link" type="text" autocomplete="off" class="form-control {{ $errors->has('link') ? ' is-invalid' : '' }}"
|
||||
placeholder="http://..." value="{{ old('link') ?? $ejaculation->link }}"
|
||||
@change="onChangeLink">
|
||||
<small class="form-text text-muted">
|
||||
オカズのURLを貼り付けて登録することができます。
|
||||
</small>
|
||||
@if ($errors->has('link'))
|
||||
<div class="invalid-feedback">{{ $errors->first('link') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<metadata-preview :metadata="metadata" :state="metadataLoadState"></metadata-preview>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-12">
|
||||
<label for="note"><span class="oi oi-comment-square"></span> ノート</label>
|
||||
<textarea id="note" name="note" class="form-control {{ $errors->has('note') ? ' is-invalid' : '' }}" rows="4">{{ old('note') ?? $ejaculation->note }}</textarea>
|
||||
<small class="form-text text-muted">
|
||||
最大 500 文字
|
||||
</small>
|
||||
@if ($errors->has('note'))
|
||||
<div class="invalid-feedback">{{ $errors->first('note') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row mt-4">
|
||||
<p>オプション</p>
|
||||
<div class="form-group col-sm-12">
|
||||
<div class="custom-control custom-checkbox mb-3">
|
||||
<input id="isPrivate" name="is_private" type="checkbox" class="custom-control-input" {{ (is_bool(old('is_private')) ? old('is_private') : $ejaculation->is_private) ? 'checked' : '' }}>
|
||||
<label class="custom-control-label" for="isPrivate">
|
||||
<span class="oi oi-lock-locked"></span> このチェックインを非公開にする
|
||||
</label>
|
||||
</div>
|
||||
<div class="custom-control custom-checkbox mb-3">
|
||||
<input id="isTooSensitive" name="is_too_sensitive" type="checkbox" class="custom-control-input" {{ (is_bool(old('is_too_sensitive')) ? old('is_too_sensitive') : $ejaculation->is_too_sensitive) ? 'checked' : '' }}>
|
||||
<label class="custom-control-label" for="isTooSensitive">
|
||||
<span class="oi oi-warning"></span> チェックイン対象のオカズをより過激なオカズとして設定する
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button class="btn btn-primary" type="submit">チェックイン</button>
|
||||
<div id="checkinForm">
|
||||
<div class="text-center small" style="height: 640px;">しばらくお待ちください…</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -105,5 +21,6 @@
|
||||
@endsection
|
||||
|
||||
@push('script')
|
||||
<script id="initialState" type="application/json">@json($initialState)</script>
|
||||
<script src="{{ mix('js/checkin.js') }}"></script>
|
||||
@endpush
|
||||
|
@ -34,14 +34,19 @@
|
||||
<h5>{{ $ejaculatedSpan ?? '精通' }} <small class="text-muted">{{ $ejaculation->before_date }}{{ !empty($ejaculation->before_date) ? ' ~ ' : '' }}{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></h5>
|
||||
</div>
|
||||
<!-- tags -->
|
||||
@if ($ejaculation->is_private || $ejaculation->source === 'csv' || $ejaculation->tags->isNotEmpty())
|
||||
@if ($ejaculation->is_private || $ejaculation->source !== 'web' || $ejaculation->tags->isNotEmpty())
|
||||
<p class="mb-2">
|
||||
@if ($ejaculation->is_private)
|
||||
<span class="badge badge-warning"><span class="oi oi-lock-locked"></span> 非公開</span>
|
||||
@endif
|
||||
@if ($ejaculation->source === 'csv')
|
||||
<span class="badge badge-info"><span class="oi oi-cloud-upload"></span> インポート</span>
|
||||
@endif
|
||||
@switch ($ejaculation->source)
|
||||
@case ('csv')
|
||||
<span class="badge badge-info"><span class="oi oi-cloud-upload"></span> インポート</span>
|
||||
@break
|
||||
@case ('webhook')
|
||||
<span class="badge badge-info" data-toggle="tooltip" title="Webhookからチェックイン"><span class="oi oi-flash"></span></span>
|
||||
@break
|
||||
@endswitch
|
||||
@foreach ($ejaculation->tags as $tag)
|
||||
<a class="badge badge-secondary" href="{{ route('search', ['q' => $tag->name]) }}"><span class="oi oi-tag"></span> {{ $tag->name }}</a>
|
||||
@endforeach
|
||||
|
@ -10,6 +10,8 @@
|
||||
href="{{ route('setting') }}"><span class="oi oi-person mr-1"></span> プロフィール</a>
|
||||
<a class="list-group-item list-group-item-action {{ Route::currentRouteName() === 'setting.privacy' ? 'active' : '' }}"
|
||||
href="{{ route('setting.privacy') }}"><span class="oi oi-shield mr-1"></span> プライバシー</a>
|
||||
<a class="list-group-item list-group-item-action {{ Route::currentRouteName() === 'setting.webhooks' ? 'active' : '' }}"
|
||||
href="{{ route('setting.webhooks') }}"><span class="oi oi-link-intact mr-1"></span> Webhook</a>
|
||||
<a class="list-group-item list-group-item-action {{ Route::currentRouteName() === 'setting.import' ? 'active' : '' }}"
|
||||
href="{{ route('setting.import') }}"><span class="oi oi-data-transfer-upload mr-1"></span> データのインポート</a>
|
||||
<a class="list-group-item list-group-item-action {{ Route::currentRouteName() === 'setting.export' ? 'active' : '' }}"
|
||||
|
70
resources/views/setting/webhooks.blade.php
Normal file
70
resources/views/setting/webhooks.blade.php
Normal file
@ -0,0 +1,70 @@
|
||||
@extends('setting.base')
|
||||
|
||||
@section('title', 'Webhook')
|
||||
|
||||
@section('tab-content')
|
||||
<h3>Incoming Webhook</h3>
|
||||
<hr>
|
||||
<p>さまざまなシステムと連携してチェックインを行うためのWebhook URLを作成することができます。APIドキュメントは<a href="{{ url('/apidoc.html') }}">こちら</a>から参照いただけます。</p>
|
||||
<h4>新規作成</h4>
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="font-weight-bold">おことわり</h6>
|
||||
<p>Webhook APIは予告なく仕様変更を行う場合がございます。また、サーバに対する過剰なリクエストや、不審な公開チェックインを繰り返している場合には管理者の裁量によって予告なく無効化(削除)する場合があります。</p>
|
||||
<p>通常利用と同様、1分以内のチェックインは禁止されていることを考慮してください。また、テスト目的であれば非公開チェックインをご活用ください。</p>
|
||||
<hr>
|
||||
@if (count($webhooks) >= $webhooksLimit)
|
||||
<p class="my-0 text-danger">1ユーザーが作成可能なWebhookは、{{ $webhooksLimit }}件までに制限されています。</p>
|
||||
@else
|
||||
<form action="{{ route('setting.webhooks.store') }}" method="post">
|
||||
{{ csrf_field() }}
|
||||
<div class="form-group">
|
||||
<label for="name">名前 (メモ)</label>
|
||||
<input id="name" class="form-control {{ $errors->has('name') ? ' is-invalid' : '' }}" name="name" type="text" required>
|
||||
<small class="form-text text-muted">後で分かるように名前を付けておいてください。</small>
|
||||
@if ($errors->has('name'))
|
||||
<div class="invalid-feedback">{{ $errors->first('name') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">新規作成</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if (!$webhooks->isEmpty())
|
||||
<h4 class="mt-4">作成済みのWebhook</h4>
|
||||
<div class="list-group mt-3">
|
||||
@foreach ($webhooks as $webhook)
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<div>{{ $webhook->name }}</div>
|
||||
<input class="webhook-url form-control form-control-sm mt-1" type="text" value="{{ url('/api/webhooks/checkin/' . $webhook->id) }}" readonly>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<button class="btn btn-outline-secondary copy-to-clipboard" type="button" data-toggle="popover" data-trigger="manual" data-placement="top" data-content="コピーしました!">コピー</button>
|
||||
<button class="btn btn-outline-danger" type="button" data-target="#deleteIncomingWebhookModal" data-id="{{ $webhook->id }}">削除</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@component('components.modal', ['id' => 'deleteIncomingWebhookModal'])
|
||||
@slot('title')
|
||||
削除確認
|
||||
@endslot
|
||||
Webhookを削除してもよろしいですか?
|
||||
@slot('footer')
|
||||
<form action="{{ route('setting.webhooks.destroy', ['webhook' => '@']) }}" method="post">
|
||||
{{ csrf_field() }}
|
||||
{{ method_field('DELETE') }}
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">キャンセル</button>
|
||||
<button type="submit" class="btn btn-danger">削除</button>
|
||||
</form>
|
||||
@endslot
|
||||
@endcomponent
|
||||
@endsection
|
||||
|
||||
@push('script')
|
||||
<script src="{{ mix('js/setting/webhooks.js') }}"></script>
|
||||
@endpush
|
@ -51,14 +51,19 @@
|
||||
<h5>{{ $ejaculation->ejaculated_span ?? '精通' }} <a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->before_date }}{{ !empty($ejaculation->before_date) ? ' ~ ' : '' }}{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a></h5>
|
||||
</div>
|
||||
<!-- tags -->
|
||||
@if ($ejaculation->is_private || $ejaculation->source === 'csv' || $ejaculation->tags->isNotEmpty())
|
||||
@if ($ejaculation->is_private || $ejaculation->source !== 'web' || $ejaculation->tags->isNotEmpty())
|
||||
<p class="mb-2">
|
||||
@if ($ejaculation->is_private)
|
||||
<span class="badge badge-warning"><span class="oi oi-lock-locked"></span> 非公開</span>
|
||||
@endif
|
||||
@if ($ejaculation->source === 'csv')
|
||||
<span class="badge badge-info"><span class="oi oi-cloud-upload"></span> インポート</span>
|
||||
@endif
|
||||
@switch ($ejaculation->source)
|
||||
@case ('csv')
|
||||
<span class="badge badge-info"><span class="oi oi-cloud-upload"></span> インポート</span>
|
||||
@break
|
||||
@case ('webhook')
|
||||
<span class="badge badge-info" data-toggle="tooltip" title="Webhookからチェックイン"><span class="oi oi-flash"></span></span>
|
||||
@break
|
||||
@endswitch
|
||||
@foreach ($ejaculation->tags as $tag)
|
||||
<a class="badge badge-secondary" href="{{ route('search', ['q' => $tag->name]) }}"><span class="oi oi-tag"></span> {{ $tag->name }}</a>
|
||||
@endforeach
|
||||
|
@ -15,9 +15,15 @@
|
||||
// return $request->user();
|
||||
//});
|
||||
|
||||
Route::get('/checkin/card', 'Api\\CardController@show');
|
||||
Route::middleware('stateful')->group(function () {
|
||||
Route::get('/checkin/card', 'Api\\CardController@show')
|
||||
->middleware('throttle:30|180,1,card');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::post('/likes', 'Api\\LikeController@store');
|
||||
Route::delete('/likes/{id}', 'Api\\LikeController@destroy');
|
||||
Route::middleware(['throttle:60,1', 'auth'])->group(function () {
|
||||
Route::post('/likes', 'Api\\LikeController@store');
|
||||
Route::delete('/likes/{id}', 'Api\\LikeController@destroy');
|
||||
});
|
||||
});
|
||||
|
||||
Route::post('/webhooks/checkin/{webhook}', 'Api\\WebhookController@checkin')
|
||||
->middleware('throttle:15,15,checkin_webhook');
|
||||
|
@ -39,6 +39,9 @@ Route::middleware('auth')->group(function () {
|
||||
Route::post('/setting/profile', 'SettingController@updateProfile')->name('setting.profile.update');
|
||||
Route::get('/setting/privacy', 'SettingController@privacy')->name('setting.privacy');
|
||||
Route::post('/setting/privacy', 'SettingController@updatePrivacy')->name('setting.privacy.update');
|
||||
Route::get('/setting/webhooks', 'SettingController@webhooks')->name('setting.webhooks');
|
||||
Route::post('/setting/webhooks', 'SettingController@storeWebhooks')->name('setting.webhooks.store');
|
||||
Route::delete('/setting/webhooks/{webhook}', 'SettingController@destroyWebhooks')->name('setting.webhooks.destroy');
|
||||
Route::get('/setting/import', 'SettingController@import')->name('setting.import');
|
||||
Route::post('/setting/import', 'SettingController@storeImport')->name('setting.import');
|
||||
Route::delete('/setting/import', 'SettingController@destroyImport')->name('setting.import.destroy');
|
||||
|
2
storage/content_providers_lock/.gitignore
vendored
Normal file
2
storage/content_providers_lock/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
148
tests/Feature/Api/Webhook/CheckinWebhookTest.php
Normal file
148
tests/Feature/Api/Webhook/CheckinWebhookTest.php
Normal file
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Webhook;
|
||||
|
||||
use App\CheckinWebhook;
|
||||
use App\Ejaculation;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CheckinWebhookTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed();
|
||||
Carbon::setTestNow('2020-07-21 19:19:19');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
|
||||
public function testSuccessful()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
$webhook = factory(CheckinWebhook::class)->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/checkin/' . $webhook->id, [
|
||||
'checked_in_at' => Carbon::create(2019, 7, 21, 19, 19, 19)->toIso8601String(),
|
||||
'note' => 'test test test',
|
||||
'link' => 'http://example.com',
|
||||
'tags' => ['foo', 'bar'],
|
||||
'is_private' => false,
|
||||
'is_too_sensitive' => false,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', 200);
|
||||
|
||||
$checkinId = $response->json('checkin.id');
|
||||
$ejaculation = Ejaculation::find($checkinId);
|
||||
$this->assertEquals(Carbon::create(2019, 7, 21, 19, 19, 0), $ejaculation->ejaculated_date);
|
||||
$this->assertSame('test test test', $ejaculation->note);
|
||||
$this->assertSame('http://example.com', $ejaculation->link);
|
||||
$this->assertCount(2, $ejaculation->tags);
|
||||
$this->assertFalse($ejaculation->is_private);
|
||||
$this->assertFalse($ejaculation->is_too_sensitive);
|
||||
$this->assertSame(Ejaculation::SOURCE_WEBHOOK, $ejaculation->source);
|
||||
$this->assertNotEmpty($ejaculation->checkin_webhook_id);
|
||||
}
|
||||
|
||||
public function testSuccessfulPrivateAndSensitive()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
$webhook = factory(CheckinWebhook::class)->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/checkin/' . $webhook->id, [
|
||||
'is_private' => true,
|
||||
'is_too_sensitive' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', 200);
|
||||
|
||||
$checkinId = $response->json('checkin.id');
|
||||
$ejaculation = Ejaculation::find($checkinId);
|
||||
$this->assertTrue($ejaculation->is_private);
|
||||
$this->assertTrue($ejaculation->is_too_sensitive);
|
||||
$this->assertSame(Ejaculation::SOURCE_WEBHOOK, $ejaculation->source);
|
||||
$this->assertNotEmpty($ejaculation->checkin_webhook_id);
|
||||
}
|
||||
|
||||
public function testSuccessfulAllDefault()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
$webhook = factory(CheckinWebhook::class)->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/checkin/' . $webhook->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', 200);
|
||||
|
||||
$checkinId = $response->json('checkin.id');
|
||||
$ejaculation = Ejaculation::find($checkinId);
|
||||
$this->assertEquals(Carbon::create(2020, 7, 21, 19, 19, 0), $ejaculation->ejaculated_date);
|
||||
$this->assertEmpty($ejaculation->note);
|
||||
$this->assertEmpty($ejaculation->link);
|
||||
$this->assertEmpty($ejaculation->tags);
|
||||
$this->assertFalse($ejaculation->is_private);
|
||||
$this->assertFalse($ejaculation->is_too_sensitive);
|
||||
$this->assertSame(Ejaculation::SOURCE_WEBHOOK, $ejaculation->source);
|
||||
$this->assertNotEmpty($ejaculation->checkin_webhook_id);
|
||||
}
|
||||
|
||||
public function testUserDestroyed()
|
||||
{
|
||||
$webhook = factory(CheckinWebhook::class)->create(['user_id' => null]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/checkin/' . $webhook->id);
|
||||
|
||||
$response->assertStatus(404)
|
||||
->assertJsonPath('status', 404)
|
||||
->assertJsonPath('error.message', 'The webhook is unavailable');
|
||||
}
|
||||
|
||||
public function testValidationFailed()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
$webhook = factory(CheckinWebhook::class)->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/checkin/' . $webhook->id, [
|
||||
'checked_in_at' => new Carbon('1999-12-31T23:59:00+0900'),
|
||||
'tags' => [
|
||||
'Has spaces'
|
||||
]
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('status', 422)
|
||||
->assertJsonPath('error.message', 'Validation failed')
|
||||
->assertJsonCount(2, 'error.violations');
|
||||
}
|
||||
|
||||
public function testConflictCheckedInAt()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
$webhook = factory(CheckinWebhook::class)->create(['user_id' => $user->id]);
|
||||
$ejaculatedDate = new Carbon('2020-07-21T19:19:00+0900');
|
||||
factory(Ejaculation::class)->create([
|
||||
'user_id' => $user->id,
|
||||
'ejaculated_date' => $ejaculatedDate
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/webhooks/checkin/' . $webhook->id, [
|
||||
'checked_in_at' => $ejaculatedDate,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('status', 422)
|
||||
->assertJsonPath('error.message', 'Checkin already exists in this time');
|
||||
}
|
||||
}
|
75
tests/Feature/Setting/WebhookTest.php
Normal file
75
tests/Feature/Setting/WebhookTest.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Setting;
|
||||
|
||||
use App\CheckinWebhook;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebhookTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed();
|
||||
}
|
||||
|
||||
public function testStoreWebhooks()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->post('/setting/webhooks', ['name' => 'example']);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertViewIs('setting.webhooks');
|
||||
$this->assertDatabaseHas('checkin_webhooks', ['user_id' => $user->id, 'name' => 'example']);
|
||||
}
|
||||
|
||||
public function testStoreWebhooksHas9Hooks()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
$webhooks = factory(CheckinWebhook::class, CheckinWebhook::PER_USER_LIMIT - 1)->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->post('/setting/webhooks', ['name' => 'example9']);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertViewIs('setting.webhooks');
|
||||
$this->assertDatabaseHas('checkin_webhooks', ['user_id' => $user->id, 'name' => 'example9']);
|
||||
}
|
||||
|
||||
public function testStoreWebhooksHas10Hooks()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
$webhooks = factory(CheckinWebhook::class, CheckinWebhook::PER_USER_LIMIT)->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->post('/setting/webhooks', ['name' => 'example10']);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertViewIs('setting.webhooks');
|
||||
$this->assertDatabaseMissing('checkin_webhooks', ['user_id' => $user->id, 'name' => 'example10']);
|
||||
}
|
||||
|
||||
public function testDestroyWebhooks()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
$webhook = factory(CheckinWebhook::class)->create(['user_id' => $user->id]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->followingRedirects()
|
||||
->delete('/setting/webhooks/' . $webhook->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertViewIs('setting.webhooks')
|
||||
->assertSee('削除しました');
|
||||
$this->assertTrue($webhook->refresh()->trashed());
|
||||
}
|
||||
}
|
@ -277,6 +277,48 @@ class CheckinCsvImporterTest extends TestCase
|
||||
$this->assertEquals(Ejaculation::SOURCE_CSV, $ejaculation->source);
|
||||
}
|
||||
|
||||
public function testIsPrivateUTF8()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
|
||||
$importer = new CheckinCsvImporter($user, __DIR__ . '/../../fixture/Csv/private.utf8.csv');
|
||||
$importer->execute();
|
||||
|
||||
$ejaculations = $user->ejaculations()->orderBy('ejaculated_date')->get();
|
||||
|
||||
$this->assertSame(9, $ejaculations->count());
|
||||
$this->assertTrue($ejaculations[0]->is_private);
|
||||
$this->assertTrue($ejaculations[1]->is_private);
|
||||
$this->assertTrue($ejaculations[2]->is_private);
|
||||
$this->assertTrue($ejaculations[3]->is_private);
|
||||
$this->assertFalse($ejaculations[4]->is_private);
|
||||
$this->assertFalse($ejaculations[5]->is_private);
|
||||
$this->assertFalse($ejaculations[6]->is_private);
|
||||
$this->assertFalse($ejaculations[7]->is_private);
|
||||
$this->assertFalse($ejaculations[8]->is_private);
|
||||
}
|
||||
|
||||
public function testIsTooSensitiveUTF8()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
|
||||
$importer = new CheckinCsvImporter($user, __DIR__ . '/../../fixture/Csv/too-sensitive.utf8.csv');
|
||||
$importer->execute();
|
||||
|
||||
$ejaculations = $user->ejaculations()->orderBy('ejaculated_date')->get();
|
||||
|
||||
$this->assertSame(9, $ejaculations->count());
|
||||
$this->assertTrue($ejaculations[0]->is_too_sensitive);
|
||||
$this->assertTrue($ejaculations[1]->is_too_sensitive);
|
||||
$this->assertTrue($ejaculations[2]->is_too_sensitive);
|
||||
$this->assertTrue($ejaculations[3]->is_too_sensitive);
|
||||
$this->assertFalse($ejaculations[4]->is_too_sensitive);
|
||||
$this->assertFalse($ejaculations[5]->is_too_sensitive);
|
||||
$this->assertFalse($ejaculations[6]->is_too_sensitive);
|
||||
$this->assertFalse($ejaculations[7]->is_too_sensitive);
|
||||
$this->assertFalse($ejaculations[8]->is_too_sensitive);
|
||||
}
|
||||
|
||||
public function testDontThrowUniqueKeyViolation()
|
||||
{
|
||||
$user = factory(User::class)->create();
|
||||
|
185
tests/Unit/Services/MetadataResolverServiceTest.php
Normal file
185
tests/Unit/Services/MetadataResolverServiceTest.php
Normal file
@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\ContentProvider;
|
||||
use App\MetadataResolver\MetadataResolver;
|
||||
use App\MetadataResolver\ResolverCircuitBreakException;
|
||||
use App\MetadataResolver\UncaughtResolverException;
|
||||
use App\Services\MetadataResolveService;
|
||||
use Carbon\Carbon;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use GuzzleHttp\Exception\ServerException;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MetadataResolverServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed();
|
||||
Carbon::setTestNow('2020-07-21 19:19:19');
|
||||
// FIXME: 今書かれてるテストはresolveのHTTPリクエストのみを考慮しているので、ContentProviderにデータがないとリクエスト回数がずれる
|
||||
factory(ContentProvider::class)->create();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
|
||||
public function testOnRuntimeException()
|
||||
{
|
||||
$this->mock(MetadataResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolve')->andReturnUsing(function ($url) {
|
||||
throw new \RuntimeException('Something happened!');
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
$service = app()->make(MetadataResolveService::class);
|
||||
$service->execute('http://example.com');
|
||||
} catch (UncaughtResolverException $e) {
|
||||
$this->assertDatabaseHas('metadata', [
|
||||
'url' => 'http://example.com',
|
||||
'error_at' => new Carbon('2020-07-21 19:19:19'),
|
||||
'error_count' => 1,
|
||||
'error_exception_class' => \RuntimeException::class,
|
||||
'error_http_code' => null,
|
||||
'error_body' => 'Something happened!',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
|
||||
public function testOnHttpClientError()
|
||||
{
|
||||
$handler = HandlerStack::create(new MockHandler([new Response(404)]));
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->instance(Client::class, $client);
|
||||
|
||||
try {
|
||||
$service = app()->make(MetadataResolveService::class);
|
||||
$service->execute('http://example.com');
|
||||
} catch (UncaughtResolverException $e) {
|
||||
$this->assertDatabaseHas('metadata', [
|
||||
'url' => 'http://example.com',
|
||||
'error_at' => new Carbon('2020-07-21 19:19:19'),
|
||||
'error_count' => 1,
|
||||
'error_exception_class' => ClientException::class,
|
||||
'error_http_code' => 404,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
|
||||
public function testOnHttpServerError()
|
||||
{
|
||||
$handler = HandlerStack::create(new MockHandler([new Response(503), new Response(503)]));
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->instance(Client::class, $client);
|
||||
|
||||
try {
|
||||
$service = app()->make(MetadataResolveService::class);
|
||||
$service->execute('http://example.com');
|
||||
} catch (UncaughtResolverException $e) {
|
||||
$this->assertDatabaseHas('metadata', [
|
||||
'url' => 'http://example.com',
|
||||
'error_at' => new Carbon('2020-07-21 19:19:19'),
|
||||
'error_count' => 1,
|
||||
'error_exception_class' => ServerException::class,
|
||||
'error_http_code' => 503,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
|
||||
public function testCircuitBreak()
|
||||
{
|
||||
$this->mock(MetadataResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolve')->andReturnUsing(function ($url) {
|
||||
throw new \RuntimeException('Something happened!');
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
try {
|
||||
$service = app()->make(MetadataResolveService::class);
|
||||
$service->execute('http://example.com');
|
||||
} catch (UncaughtResolverException $e) {
|
||||
}
|
||||
}
|
||||
} catch (ResolverCircuitBreakException $e) {
|
||||
$this->assertDatabaseHas('metadata', [
|
||||
'url' => 'http://example.com',
|
||||
'error_at' => new Carbon('2020-07-21 19:19:19'),
|
||||
'error_count' => 5,
|
||||
'error_exception_class' => \RuntimeException::class,
|
||||
'error_http_code' => null,
|
||||
'error_body' => 'Something happened!',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
|
||||
public function testOnResurrect()
|
||||
{
|
||||
$successBody = <<<HTML
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="og:title" content="OGP Title">
|
||||
<meta name="og:description" content="OGP Description">
|
||||
<title>Test Document</title>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
$handler = HandlerStack::create(new MockHandler([
|
||||
new Response(404),
|
||||
new Response(200, ['Content-Type' => 'text/html'], $successBody),
|
||||
]));
|
||||
$client = new Client(['handler' => $handler]);
|
||||
$this->instance(Client::class, $client);
|
||||
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
try {
|
||||
$service = app()->make(MetadataResolveService::class);
|
||||
$service->execute('http://example.com');
|
||||
} catch (UncaughtResolverException $e) {
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertDatabaseHas('metadata', [
|
||||
'url' => 'http://example.com',
|
||||
'title' => 'OGP Title',
|
||||
'description' => 'OGP Description',
|
||||
'image' => '',
|
||||
'error_at' => null,
|
||||
'error_count' => 0,
|
||||
'error_exception_class' => null,
|
||||
'error_http_code' => null,
|
||||
'error_body' => null,
|
||||
]);
|
||||
}
|
||||
}
|
10
tests/fixture/Csv/private.utf8.csv
vendored
Normal file
10
tests/fixture/Csv/private.utf8.csv
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
日時,非公開
|
||||
2020/01/23 06:01:00,true
|
||||
2020/01/23 06:02:00,TRUE
|
||||
2020/01/23 06:03:00,True
|
||||
2020/01/23 06:04:00,1
|
||||
2020/01/23 07:01:00,false
|
||||
2020/01/23 07:02:00,FALSE
|
||||
2020/01/23 07:03:00,False
|
||||
2020/01/23 07:04:00,0
|
||||
2020/01/23 07:05:00,
|
|
10
tests/fixture/Csv/too-sensitive.utf8.csv
vendored
Normal file
10
tests/fixture/Csv/too-sensitive.utf8.csv
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
日時,センシティブ
|
||||
2020/01/23 06:01:00,true
|
||||
2020/01/23 06:02:00,TRUE
|
||||
2020/01/23 06:03:00,True
|
||||
2020/01/23 06:04:00,1
|
||||
2020/01/23 07:01:00,false
|
||||
2020/01/23 07:02:00,FALSE
|
||||
2020/01/23 07:03:00,False
|
||||
2020/01/23 07:04:00,0
|
||||
2020/01/23 07:05:00,
|
|
@ -5,7 +5,9 @@
|
||||
"module": "es2015",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"experimentalDecorators": true
|
||||
"experimentalDecorators": true,
|
||||
"jsx": "react",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": [
|
||||
"resources/assets/js/**/*"
|
||||
|
3
webpack.mix.js
vendored
3
webpack.mix.js
vendored
@ -20,7 +20,8 @@ mix.ts('resources/assets/js/app.ts', 'public/js')
|
||||
.ts('resources/assets/js/setting/privacy.ts', 'public/js/setting')
|
||||
.ts('resources/assets/js/setting/import.ts', 'public/js/setting')
|
||||
.ts('resources/assets/js/setting/deactivate.ts', 'public/js/setting')
|
||||
.ts('resources/assets/js/checkin.ts', 'public/js')
|
||||
.ts('resources/assets/js/setting/webhooks.ts', 'public/js/setting')
|
||||
.ts('resources/assets/js/checkin.tsx', 'public/js')
|
||||
.sass('resources/assets/sass/app.scss', 'public/css')
|
||||
.autoload({
|
||||
jquery: ['$', 'jQuery', 'window.jQuery'],
|
||||
|
Loading…
Reference in New Issue
Block a user