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:
ak
2026-08-12 01:15:38 +08:00
commit 263b98b218
337 changed files with 31393 additions and 0 deletions
@@ -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());
}
}
+145
View File
@@ -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', '已退出登录');
}
}
+240
View File
@@ -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.');
}
}
+10
View File
@@ -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']
);
}
}
+98
View File
@@ -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']);
}
}