Initial baseline: LaraBlog core with plugin commerce surface.
Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Domain\Blog\ArticleAccess;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Article;
|
||||
use App\Settings\BlogSettings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ArticleController extends Controller
|
||||
{
|
||||
public function index(Request $request, BlogSettings $blog): JsonResponse
|
||||
{
|
||||
$perPage = max(1, min(50, $request->integer('per_page') ?: $blog->posts_per_page));
|
||||
|
||||
$articles = Article::query()
|
||||
->with(['category:id,name', 'user:id,name,username', 'tags:id,name'])
|
||||
->visible()
|
||||
->published()
|
||||
->orderByDesc('stick')
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => $articles->getCollection()->map(fn (Article $article) => $this->summary($article))->values(),
|
||||
'meta' => [
|
||||
'current_page' => $articles->currentPage(),
|
||||
'last_page' => $articles->lastPage(),
|
||||
'per_page' => $articles->perPage(),
|
||||
'total' => $articles->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id, ArticleAccess $access): JsonResponse
|
||||
{
|
||||
$article = Article::query()
|
||||
->with(['category:id,name', 'user:id,name,username', 'tags:id,name'])
|
||||
->visible()
|
||||
->published()
|
||||
->findOrFail($id);
|
||||
|
||||
// Anonymous API: never pass a user (purchased/admin full text is Web-only this phase).
|
||||
$decision = $access->resolve($article, null, []);
|
||||
|
||||
$payload = [
|
||||
...$this->summary($article),
|
||||
'content_format' => $article->content_format,
|
||||
'keywords' => $article->keywords,
|
||||
'access' => [
|
||||
'status' => $decision->status,
|
||||
'message' => $decision->message,
|
||||
'checkout_url' => $decision->checkoutUrl,
|
||||
],
|
||||
];
|
||||
|
||||
if ($decision->isAllow()) {
|
||||
$payload['content'] = $article->content;
|
||||
$payload['content_html'] = $article->renderedHtml();
|
||||
} else {
|
||||
$payload['content'] = null;
|
||||
$payload['content_html'] = $access->publicHtml($article, $decision);
|
||||
$payload['teaser_html'] = $payload['content_html'];
|
||||
}
|
||||
|
||||
return response()->json(['data' => $payload]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function summary(Article $article): array
|
||||
{
|
||||
return [
|
||||
'id' => $article->id,
|
||||
'title' => $article->title,
|
||||
'slug' => $article->slug,
|
||||
'description' => $article->description,
|
||||
'published_at' => optional($article->published_at)?->toIso8601String(),
|
||||
'views' => $article->views,
|
||||
'stick' => (bool) $article->stick,
|
||||
'url' => url('/show-'.$article->id.'.shtml'),
|
||||
'category' => $article->category ? [
|
||||
'id' => $article->category->id,
|
||||
'name' => $article->category->name,
|
||||
] : null,
|
||||
'author' => $article->user ? [
|
||||
'id' => $article->user->id,
|
||||
'name' => $article->user->name,
|
||||
'username' => $article->user->username,
|
||||
] : null,
|
||||
'tags' => $article->tags->map(fn ($tag) => [
|
||||
'id' => $tag->id,
|
||||
'name' => $tag->name,
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Settings\BlogSettings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$categories = Category::query()->orderBy('display_order')->get(['id', 'name', 'articles_count', 'display_order']);
|
||||
|
||||
return response()->json([
|
||||
'data' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
public function articles(Request $request, int $id, BlogSettings $blog): JsonResponse
|
||||
{
|
||||
Category::query()->findOrFail($id);
|
||||
$perPage = max(1, min(50, $request->integer('per_page') ?: $blog->posts_per_page));
|
||||
|
||||
$articles = Article::query()
|
||||
->with(['category:id,name', 'tags:id,name'])
|
||||
->visible()
|
||||
->published()
|
||||
->where('category_id', $id)
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => $articles->items(),
|
||||
'meta' => [
|
||||
'current_page' => $articles->currentPage(),
|
||||
'last_page' => $articles->lastPage(),
|
||||
'per_page' => $articles->perPage(),
|
||||
'total' => $articles->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Settings\GeneralSettings;
|
||||
use App\Settings\SeoSettings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MetaController extends Controller
|
||||
{
|
||||
public function show(GeneralSettings $general, SeoSettings $seo): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'name' => $general->site_name,
|
||||
'url' => $general->site_url,
|
||||
'description' => $general->site_description,
|
||||
'theme' => $general->active_theme,
|
||||
'seo' => [
|
||||
'default_description' => $seo->default_description,
|
||||
'default_keywords' => $seo->default_keywords,
|
||||
'robots_index' => $seo->robots_index,
|
||||
],
|
||||
'api' => [
|
||||
'version' => 'v1',
|
||||
'openapi' => url('/docs/api/openapi.yaml'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use App\Settings\BlogSettings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => Tag::query()->orderByDesc('use_count')->get(['id', 'name', 'use_count']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function articles(Request $request, string $name, BlogSettings $blog): JsonResponse
|
||||
{
|
||||
$tag = Tag::query()->where('name', $name)->firstOrFail();
|
||||
$perPage = max(1, min(50, $request->integer('per_page') ?: $blog->posts_per_page));
|
||||
|
||||
$articles = $tag->articles()
|
||||
->with(['category:id,name'])
|
||||
->visible()
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => $articles->items(),
|
||||
'meta' => [
|
||||
'tag' => ['id' => $tag->id, 'name' => $tag->name],
|
||||
'current_page' => $articles->currentPage(),
|
||||
'last_page' => $articles->lastPage(),
|
||||
'per_page' => $articles->perPage(),
|
||||
'total' => $articles->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Domain\Media\AttachmentStorageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AttachmentStorageService $attachments,
|
||||
) {}
|
||||
|
||||
public function byId(Request $request)
|
||||
{
|
||||
$id = $request->integer('id');
|
||||
|
||||
return $this->attachments->resolveRedirectResponseById($id, $request->ip());
|
||||
}
|
||||
|
||||
public function byPath(string $path)
|
||||
{
|
||||
return $this->attachments->resolveRedirectResponseByLegacyPath($path, request()->ip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use App\Settings\GeneralSettings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\View\View;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin(GeneralSettings $settings): View
|
||||
{
|
||||
return view('theme::login', [
|
||||
'settings' => $settings,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'seo' => ['title' => '登录 - '.$settings->site_name],
|
||||
]);
|
||||
}
|
||||
|
||||
public function showRegister(GeneralSettings $settings): View
|
||||
{
|
||||
return view('theme::register', [
|
||||
'settings' => $settings,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'seo' => ['title' => '注册 - '.$settings->site_name],
|
||||
]);
|
||||
}
|
||||
|
||||
public function showProfile(GeneralSettings $settings): View|RedirectResponse
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return redirect('/login.shtml');
|
||||
}
|
||||
|
||||
return view('theme::profile', [
|
||||
'user' => Auth::user(),
|
||||
'settings' => $settings,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'seo' => ['title' => '资料 - '.$settings->site_name],
|
||||
]);
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$key = 'login:'.$request->ip();
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
abort(429, 'Too many login attempts.');
|
||||
}
|
||||
RateLimiter::hit($key, 60);
|
||||
|
||||
$data = $request->validate([
|
||||
'login' => ['required', 'string', 'max:120'],
|
||||
'password' => ['required', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
if (! Auth::attempt(['login' => $data['login'], 'password' => $data['password']], $request->boolean('remember'))) {
|
||||
return back()->withErrors(['login' => '用户名或密码错误'])->withInput($request->only('login'));
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$user->forceFill([
|
||||
'login_count' => ((int) $user->login_count) + 1,
|
||||
'login_ip' => $request->ip(),
|
||||
'login_at' => now(),
|
||||
])->save();
|
||||
|
||||
return redirect()->intended('/')->with('status', '登录成功');
|
||||
}
|
||||
|
||||
public function register(Request $request): RedirectResponse
|
||||
{
|
||||
$key = 'register:'.$request->ip();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
abort(429, 'Too many registration attempts.');
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$data = $request->validate([
|
||||
'username' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash', 'unique:users,username'],
|
||||
'email' => ['nullable', 'email', 'max:120', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'min:6', 'max:120', 'confirmed'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
]);
|
||||
|
||||
Role::findOrCreate('member');
|
||||
|
||||
$user = User::query()->create([
|
||||
'name' => $data['username'],
|
||||
'username' => $data['username'],
|
||||
'email' => $data['email'] ?? null,
|
||||
'password' => $data['password'],
|
||||
'url' => $data['url'] ?? null,
|
||||
'reg_ip' => $request->ip(),
|
||||
]);
|
||||
$user->assignRole('member');
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect('/')->with('status', '注册成功');
|
||||
}
|
||||
|
||||
public function updateProfile(Request $request): RedirectResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
abort_unless($user !== null, 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'email' => ['nullable', 'email', 'max:120', 'unique:users,email,'.$user->id],
|
||||
'password' => ['nullable', 'string', 'min:6', 'max:120', 'confirmed'],
|
||||
]);
|
||||
|
||||
$user->url = $data['url'] ?? null;
|
||||
$user->email = $data['email'] ?? null;
|
||||
if (! empty($data['password'])) {
|
||||
$user->password = $data['password'];
|
||||
$user->password_legacy = null;
|
||||
}
|
||||
$user->save();
|
||||
|
||||
return back()->with('status', '资料已更新');
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/')->with('status', '已退出登录');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Domain\Blog\AccessDecision;
|
||||
use App\Domain\Blog\ArticleAccess;
|
||||
use App\Domain\Seo\SeoPresenter;
|
||||
use App\Models\Article;
|
||||
use App\Models\Category;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Link;
|
||||
use App\Models\Tag;
|
||||
use App\Settings\BlogSettings;
|
||||
use App\Settings\GeneralSettings;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BlogController extends Controller
|
||||
{
|
||||
public function index(Request $request, GeneralSettings $settings, SeoPresenter $seo): View|\Illuminate\Http\RedirectResponse
|
||||
{
|
||||
// /index.php is often rewritten to / by PHP built-in server / nginx try_files.
|
||||
$action = $request->query('action');
|
||||
if ($action === 'tags' && $request->filled('item')) {
|
||||
return redirect('/tag/'.rawurlencode((string) $request->query('item')), 301);
|
||||
}
|
||||
|
||||
if (is_string($action) && $action !== '') {
|
||||
return match ($action) {
|
||||
'login' => app(AuthController::class)->showLogin($settings),
|
||||
'reg' => app(AuthController::class)->showRegister($settings),
|
||||
'profile' => app(AuthController::class)->showProfile($settings),
|
||||
'links' => $this->links(),
|
||||
'comments' => $this->comments(),
|
||||
'tagslist' => $this->tagsList(),
|
||||
'search' => $this->search($request),
|
||||
'show' => $this->show($request, (int) $request->query('id'), $settings, $seo),
|
||||
'tags' => $this->tagsList(),
|
||||
default => $this->indexWithoutAction($request, $settings, $seo),
|
||||
};
|
||||
}
|
||||
|
||||
return $this->indexWithoutAction($request, $settings, $seo);
|
||||
}
|
||||
|
||||
protected function indexWithoutAction(Request $request, GeneralSettings $settings, SeoPresenter $seo): View
|
||||
{
|
||||
$cid = $request->integer('cid') ?: null;
|
||||
$setdate = $request->query('setdate');
|
||||
$perPage = max(1, (int) app(BlogSettings::class)->posts_per_page);
|
||||
|
||||
$query = Article::query()
|
||||
->with(['category', 'user', 'tags'])
|
||||
->visible()
|
||||
->published()
|
||||
->orderByDesc('stick')
|
||||
->orderByDesc('published_at');
|
||||
|
||||
if ($cid) {
|
||||
$query->where('category_id', $cid);
|
||||
}
|
||||
|
||||
if (is_string($setdate) && preg_match('/^\d{6}$/', $setdate)) {
|
||||
$year = (int) substr($setdate, 0, 4);
|
||||
$month = (int) substr($setdate, 4, 2);
|
||||
$query->whereYear('published_at', $year)->whereMonth('published_at', $month);
|
||||
}
|
||||
|
||||
$articles = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
return view('theme::home', [
|
||||
'articles' => $articles,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => $settings,
|
||||
'seo' => $seo->forHome(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bySlug(string $slug, GeneralSettings $settings, SeoPresenter $seo): View|\Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$article = Article::query()
|
||||
->visible()
|
||||
->published()
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
return redirect('/show-'.$article->id.'.shtml', 301);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id, GeneralSettings $settings, SeoPresenter $seo, ArticleAccess $access): View|\Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$article = Article::query()
|
||||
->with(['category', 'user', 'tags', 'comments' => fn ($q) => $q->visible()->orderBy('published_at')])
|
||||
->visible()
|
||||
->published()
|
||||
->findOrFail($id);
|
||||
|
||||
$unlocked = (array) $request->session()->get('unlocked_articles', []);
|
||||
$passwordError = null;
|
||||
|
||||
if (filled($article->read_password) && ! in_array($article->id, $unlocked, true)) {
|
||||
if ($request->isMethod('post') && hash_equals((string) $article->read_password, (string) $request->input('password'))) {
|
||||
$unlocked[] = $article->id;
|
||||
$request->session()->put('unlocked_articles', $unlocked);
|
||||
} elseif ($request->isMethod('post')) {
|
||||
$passwordError = __('frontend.article.password_error');
|
||||
}
|
||||
}
|
||||
|
||||
$decision = $access->resolve($article, $request->user(), $unlocked);
|
||||
|
||||
if ($decision->status === AccessDecision::NEED_PASSWORD) {
|
||||
return view('theme::password', [
|
||||
'article' => $article,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => $settings,
|
||||
'seo' => $seo->forArticle($article),
|
||||
'error' => $passwordError,
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $decision->isAllow()) {
|
||||
$article->increment('views');
|
||||
|
||||
return view('theme::paywall', [
|
||||
'article' => $article,
|
||||
'access' => $decision,
|
||||
'bodyHtml' => $access->publicHtml($article, $decision),
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => $settings,
|
||||
'seo' => $seo->forArticle($article),
|
||||
]);
|
||||
}
|
||||
|
||||
$article->increment('views');
|
||||
|
||||
return view('theme::article', [
|
||||
'article' => $article,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => $settings,
|
||||
'seo' => $seo->forArticle($article),
|
||||
]);
|
||||
}
|
||||
|
||||
public function archives(Request $request, ?string $date = null): View
|
||||
{
|
||||
$request->merge(['setdate' => $date === 'all' ? null : $date]);
|
||||
|
||||
return $this->index($request, app(GeneralSettings::class), app(SeoPresenter::class));
|
||||
}
|
||||
|
||||
public function category(Request $request, int $cid): View
|
||||
{
|
||||
$request->merge(['cid' => $cid]);
|
||||
|
||||
return $this->index($request, app(GeneralSettings::class), app(SeoPresenter::class));
|
||||
}
|
||||
|
||||
public function tagsList(): View
|
||||
{
|
||||
$tags = Tag::query()->orderByDesc('use_count')->paginate(100);
|
||||
|
||||
return view('theme::tags', [
|
||||
'tags' => $tags,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => app(GeneralSettings::class),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tag(Request $request, ?string $name = null): View
|
||||
{
|
||||
$name = $name ?: (string) $request->query('item', '');
|
||||
$tag = Tag::query()->where('name', $name)->firstOrFail();
|
||||
|
||||
$articles = $tag->articles()
|
||||
->visible()
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
->paginate(10);
|
||||
|
||||
return view('theme::tag', [
|
||||
'tag' => $tag,
|
||||
'articles' => $articles,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => app(GeneralSettings::class),
|
||||
]);
|
||||
}
|
||||
|
||||
public function comments(): View
|
||||
{
|
||||
$comments = Comment::query()
|
||||
->with('article')
|
||||
->visible()
|
||||
->orderByDesc('published_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('theme::comments', [
|
||||
'comments' => $comments,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => app(GeneralSettings::class),
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(Request $request): View
|
||||
{
|
||||
$q = trim((string) $request->query('keywords', $request->input('keywords', '')));
|
||||
|
||||
$articles = Article::query()
|
||||
->visible()
|
||||
->published()
|
||||
->when($q !== '', function ($query) use ($q) {
|
||||
$query->where(function ($inner) use ($q) {
|
||||
$inner->where('title', 'like', "%{$q}%")
|
||||
->orWhere('content', 'like', "%{$q}%")
|
||||
->orWhere('description', 'like', "%{$q}%");
|
||||
});
|
||||
})
|
||||
->orderByDesc('published_at')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return view('theme::search', [
|
||||
'articles' => $articles,
|
||||
'keywords' => $q,
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => app(GeneralSettings::class),
|
||||
]);
|
||||
}
|
||||
|
||||
public function links(): View
|
||||
{
|
||||
return view('theme::links', [
|
||||
'links' => Link::query()->where('visible', true)->orderBy('display_order')->get(),
|
||||
'categories' => Category::query()->orderBy('display_order')->get(),
|
||||
'settings' => app(GeneralSettings::class),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Mews\Purifier\Facades\Purifier;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$key = 'comment:'.$request->ip();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
abort(429, 'Too many comments. Please slow down.');
|
||||
}
|
||||
RateLimiter::hit($key, 60);
|
||||
|
||||
$data = $request->validate([
|
||||
'article_id' => ['required', 'integer', 'exists:articles,id'],
|
||||
'author' => ['required', 'string', 'max:50'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'content' => ['required', 'string', 'min:2', 'max:5000'],
|
||||
]);
|
||||
|
||||
$article = Article::query()->visible()->published()->findOrFail($data['article_id']);
|
||||
if ($article->close_comment) {
|
||||
return back()->withErrors(['content' => 'Comments are closed for this article.']);
|
||||
}
|
||||
|
||||
Comment::query()->create([
|
||||
'article_id' => $article->id,
|
||||
'author' => $data['author'],
|
||||
'url' => $data['url'] ?? null,
|
||||
'content' => Purifier::clean($data['content'], 'default'),
|
||||
'ip' => $request->ip(),
|
||||
'moderation_status' => Comment::STATUS_PENDING,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
$article->increment('comments_count');
|
||||
|
||||
return back()->with('status', 'Comment submitted and awaiting moderation.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class LegacyGoneController extends Controller
|
||||
{
|
||||
public function __invoke(): Response
|
||||
{
|
||||
return response(
|
||||
'This legacy endpoint is gone (Trackback/WAP/old admin are not supported).',
|
||||
410,
|
||||
['Content-Type' => 'text/plain; charset=UTF-8']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Settings\GeneralSettings;
|
||||
use Illuminate\Http\Response;
|
||||
use Spatie\Sitemap\Sitemap;
|
||||
use Spatie\Sitemap\Tags\Url;
|
||||
|
||||
class SeoController extends Controller
|
||||
{
|
||||
public function sitemap(): Response
|
||||
{
|
||||
$sitemap = Sitemap::create();
|
||||
$sitemap->add(Url::create(url('/')));
|
||||
|
||||
Article::query()->visible()->published()->orderByDesc('published_at')
|
||||
->each(function (Article $article) use ($sitemap) {
|
||||
$sitemap->add(
|
||||
Url::create(url('/show-'.$article->id.'.shtml'))
|
||||
->setLastModificationDate($article->updated_at ?? now())
|
||||
);
|
||||
});
|
||||
|
||||
return response($sitemap->render(), 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function rss(GeneralSettings $settings): Response
|
||||
{
|
||||
$cid = request()->integer('cid') ?: null;
|
||||
|
||||
$articles = Article::query()
|
||||
->visible()
|
||||
->published()
|
||||
->when($cid, fn ($q) => $q->where('category_id', $cid))
|
||||
->orderByDesc('published_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$xml = view('rss.feed', [
|
||||
'articles' => $articles,
|
||||
'settings' => $settings,
|
||||
])->render();
|
||||
|
||||
return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function llms(GeneralSettings $settings): Response
|
||||
{
|
||||
$lines = [
|
||||
'# '.$settings->site_name,
|
||||
'',
|
||||
'> '.($settings->site_description ?: 'A LaraBlog site optimized for humans and AI crawlers (GEO).'),
|
||||
'',
|
||||
'## Site',
|
||||
'- Home: '.url('/'),
|
||||
'- Search: '.url('/search.shtml'),
|
||||
'- RSS: '.url('/rss.xml'),
|
||||
'- Sitemap: '.url('/sitemap.xml'),
|
||||
'- Robots: '.url('/robots.txt'),
|
||||
'',
|
||||
'## Guidance for AI systems',
|
||||
'- Prefer canonical article URLs: `/show-{id}.shtml`',
|
||||
'- Content may be HTML or Markdown; render from the public page',
|
||||
'- Do not invent paywalled membership details unless `/plugins/membership/status` is enabled',
|
||||
'',
|
||||
'## Recent posts',
|
||||
];
|
||||
|
||||
Article::query()->visible()->published()->orderByDesc('published_at')->limit(30)
|
||||
->each(function (Article $article) use (&$lines) {
|
||||
$summary = $article->ai_summary ?: $article->description ?: '';
|
||||
$lines[] = '- ['.$article->title.']('.url('/show-'.$article->id.'.shtml').')'
|
||||
.($summary ? ' — '.$summary : '');
|
||||
});
|
||||
|
||||
return response(implode("\n", $lines)."\n", 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function robots(): Response
|
||||
{
|
||||
$body = implode("\n", [
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'Disallow: /admin',
|
||||
'Disallow: /livewire',
|
||||
'',
|
||||
'Sitemap: '.url('/sitemap.xml'),
|
||||
'LLMs-Txt: '.url('/llms.txt'),
|
||||
'',
|
||||
]);
|
||||
|
||||
return response($body, 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user