Merge pull request #442 from shikorism/feature/300-incoming-webhook
Incoming webhook
This commit is contained in:
		@@ -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({
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user