Merge pull request #442 from shikorism/feature/300-incoming-webhook

Incoming webhook
This commit is contained in:
shibafu 2020-08-21 01:11:00 +09:00 committed by GitHub
commit 4e521baf56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1861 additions and 35 deletions

View File

@ -11,7 +11,7 @@ insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.yml]
[*.{yml,yaml}]
indent_size = 2
[*.json]

1
.gitignore vendored
View File

@ -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
View 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;
}
}

View File

@ -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)

View File

@ -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
{
@ -68,4 +71,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
);
}
}

View 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)
]);
}
}

View File

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

View File

@ -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');

View File

@ -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'));

View File

@ -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,
],
]
];
/**

View 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);
}
}

View 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,
];
}
}

View File

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

View 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'
];
});

View File

@ -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');
}
}

View File

@ -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');
});
}
}

View File

@ -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
View 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

View File

@ -9,13 +9,15 @@
"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,.tsx resources/",
"stylelint": "stylelint resources/assets/sass/**/*"
"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.23",
"@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",
@ -27,6 +29,7 @@
"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": "^2.15.0",
"eslint": "^7.6.0",
@ -43,6 +46,7 @@
"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",

View 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);
});
});
}

View File

@ -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

View File

@ -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

View File

@ -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' : '' }}"

View 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

View File

@ -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

View File

@ -15,9 +15,13 @@
// return $request->user();
//});
Route::get('/checkin/card', 'Api\\CardController@show');
Route::get('/checkin/card', 'Api\\CardController@show')
->middleware('throttle:180,1,card');
Route::middleware('auth')->group(function () {
Route::middleware(['throttle:60,1', 'stateful', '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');

View File

@ -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');

View 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');
}
}

View 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());
}
}

1
webpack.mix.js vendored
View File

@ -20,6 +20,7 @@ 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/setting/webhooks.ts', 'public/js/setting')
.ts('resources/assets/js/checkin.tsx', 'public/js')
.sass('resources/assets/sass/app.scss', 'public/css')
.autoload({

1018
yarn.lock

File diff suppressed because it is too large Load Diff