Merge pull request #442 from shikorism/feature/300-incoming-webhook
Incoming webhook
This commit is contained in:
commit
4e521baf56
@ -11,7 +11,7 @@ insert_final_newline = true
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.yml]
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
|
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;
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
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)
|
||||
]);
|
||||
}
|
||||
}
|
@ -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,
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
|
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,
|
||||
];
|
||||
}
|
||||
}
|
@ -71,4 +71,9 @@ class User extends Authenticatable
|
||||
{
|
||||
return $this->hasMany(Like::class);
|
||||
}
|
||||
|
||||
public function checkinWebhooks()
|
||||
{
|
||||
return $this->hasMany(CheckinWebhook::class);
|
||||
}
|
||||
}
|
||||
|
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'
|
||||
];
|
||||
});
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
@ -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
|
@ -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",
|
||||
|
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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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
|
||||
|
@ -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,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');
|
||||
|
@ -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');
|
||||
|
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());
|
||||
}
|
||||
}
|
1
webpack.mix.js
vendored
1
webpack.mix.js
vendored
@ -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({
|
||||
|
Loading…
Reference in New Issue
Block a user