Skip to content

Commit dcd9121

Browse files
committed
feat: add a basic administration for the blog
1 parent 7df4864 commit dcd9121

File tree

9 files changed

+647
-0
lines changed

9 files changed

+647
-0
lines changed
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
<?php
2+
3+
namespace Happytodev\Cyclone\Controllers;
4+
5+
use Exception;
6+
use App\Auth\User;
7+
use DateTimeImmutable;
8+
use Tempest\View\View;
9+
use Tempest\Auth\Allow;
10+
use Tempest\Log\Logger;
11+
use Tempest\Router\Get;
12+
use Tempest\Http\Status;
13+
use Tempest\Router\Post;
14+
use function Tempest\map;
15+
use function Tempest\uri;
16+
use Tempest\Http\Request;
17+
use function Tempest\view;
18+
use Tempest\Http\JsonResponse;
19+
use function Tempest\root_path;
20+
21+
use Tempest\Auth\Authenticator;
22+
use Symfony\Component\Yaml\Yaml;
23+
use Tempest\Http\GenericResponse;
24+
use Tempest\Http\Session\Session;
25+
use Tempest\Http\Responses\Redirect;
26+
use Tempest\Auth\SessionAuthenticator;
27+
use League\CommonMark\MarkdownConverter;
28+
use Spatie\YamlFrontMatter\YamlFrontMatter;
29+
use Happytodev\Cyclone\Requests\PostRequest;
30+
use League\CommonMark\Environment\Environment;
31+
use Happytodev\Cyclone\Middlewares\IsUserAdmin;
32+
use Happytodev\Cyclone\Middlewares\IsUserConnected;
33+
use Happytodev\Cyclone\Repositories\PostRepository;
34+
use Tempest\Highlight\CommonMark\HighlightExtension;
35+
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
36+
37+
class AdminController
38+
{
39+
40+
private $user = null;
41+
42+
public function __construct(
43+
private Authenticator $authenticator,
44+
private PostRepository $postRepository,
45+
private Logger $logger,
46+
) {
47+
// Get the current user from the authenticator
48+
$this->user = $this->authenticator->currentUser();
49+
// Log the current user
50+
$this->logger->info("Current user in AdminController", ['user' => $this->user, 'id' => $this->user?->id]);
51+
}
52+
53+
54+
#[Get('/admin/login')]
55+
public function showLogin(): View
56+
{
57+
return view('../Views/login.view.php');
58+
}
59+
60+
#[Allow('admin')]
61+
#[Get('/admin', middleware: [IsUserConnected::class, IsUserAdmin::class])]
62+
public function index(SessionAuthenticator $session): View|Redirect
63+
{
64+
$posts = $this->postRepository->getAllPosts();
65+
66+
return view('../Views/Admin/index.view.php', posts: $posts);
67+
}
68+
69+
#[Allow('admin')]
70+
#[Get('/admin/edit/{slug}', middleware: [IsUserConnected::class, IsUserAdmin::class])]
71+
public function edit(string $slug): View
72+
{
73+
$post = $this->postRepository->findBySlug($slug);
74+
75+
$markdownPath = root_path() . DIRECTORY_SEPARATOR . $post->markdown_file_path;
76+
$environment = new Environment();
77+
78+
$environment
79+
->addExtension(new CommonMarkCoreExtension())
80+
->addExtension(new HighlightExtension());
81+
82+
if (file_exists($markdownPath)) {
83+
$markdownContent = file_get_contents($markdownPath);
84+
$document = YamlFrontMatter::parse($markdownContent);
85+
$markdownBody = $document->body(); // Raw Markdown
86+
} else {
87+
$markdownBody = 'Content not found';
88+
}
89+
90+
// ld($markdownPath, $markdownBody);
91+
// Passer le Markdown brut à la vue
92+
return view('../Views/Admin/edit.view.php', post: $post, markdown: $markdownBody);
93+
}
94+
95+
#[Post('/admin/save', middleware: [IsUserConnected::class, IsUserAdmin::class])]
96+
public function store(PostRequest $request): Redirect
97+
{
98+
99+
$slug = $request->get('slug');
100+
$title = $request->get('title');
101+
$tldr = $request->get('tldr');
102+
$markdown_file_path = $request->get('markdown_file_path');
103+
$cover_image = $request->get('cover_image');
104+
$published = $request->get('published');
105+
$markdownContent = ltrim($request->get('markdown'));
106+
107+
108+
// Find the post in the database via the slug
109+
$post = $this->postRepository->findBySlug($slug);
110+
111+
if (!$post) {
112+
// If the post is not found, redirect with an error or to a default page
113+
return new Redirect('/admin');
114+
}
115+
116+
// Update the post metadata
117+
$post->title = $title;
118+
$post->save(); // Save modifications in database
119+
120+
121+
// Build the frontmatter
122+
$frontmatter = [
123+
'title' => $title,
124+
'slug' => $slug,
125+
'tldr' => $tldr,
126+
'markdown_file_path' => $markdown_file_path,
127+
'cover_image' => $cover_image,
128+
'published' => $published,
129+
];
130+
131+
132+
// Convert frontmatter in YAML
133+
$frontmatterYaml = Yaml::dump($frontmatter);
134+
135+
// Combine frontmatter and Markdown content
136+
$fullContent = "---\n" . $frontmatterYaml . "---\n" . $markdownContent;
137+
138+
// Markdown file path
139+
$markdownPath = root_path() . DIRECTORY_SEPARATOR . $post->markdown_file_path;
140+
141+
// Write content in markdown file
142+
$writeResult = file_put_contents($markdownPath, $fullContent);
143+
144+
return new Redirect(uri([BlogController::class, 'show'], slug: $post->slug));
145+
}
146+
147+
148+
#[Post('/admin/upload-image')]
149+
public function uploadImage(Request $request): GenericResponse
150+
{
151+
// Check if a file was sended and check this is an image
152+
$files = $request->files;
153+
$file = $files['file'] ?? null;
154+
155+
if (!$file || $file->getError() || !str_starts_with($file->getClientMediaType(), 'image/')) {
156+
ll('Invalid file or not an image');
157+
$data = json_encode(['error' => 'Invalid file or not an image']);
158+
return new GenericResponse(status: Status::BAD_REQUEST, body: $data, headers: ['Content-Type' => 'application/json']);
159+
}
160+
161+
// Define storage folder
162+
$uploadDir = root_path() . '/public/uploads';
163+
ll('Upload directory: ' . $uploadDir);
164+
if (!is_dir($uploadDir)) {
165+
ll('Directory does not exist, creating...');
166+
mkdir($uploadDir, 0755, true); // Create directory if does not exist
167+
}
168+
169+
// Generate uniq filename
170+
$filename = uniqid() . '.' . explode('/', $file->getClientMediaType())[1];
171+
$filePath = $uploadDir . '/' . $filename;
172+
ll('File path: ' . $filePath);
173+
174+
try {
175+
// Move the uploaded file to the target directory
176+
$file->moveTo($filePath);
177+
178+
// Check if the file exists at the target location
179+
if (file_exists($filePath)) {
180+
$url = '/uploads/' . $filename;
181+
ll('File uploaded successfully, URL: ' . $url);
182+
$data = json_encode(['url' => $url]);
183+
return new GenericResponse(status: Status::CREATED, body: $data, headers: ['Content-Type' => 'application/json']);
184+
} else {
185+
ll('File not found after moveTo');
186+
$data = json_encode(['error' => 'Upload failed']);
187+
return new GenericResponse(status: Status::INTERNAL_SERVER_ERROR, body: $data, headers: ['Content-Type' => 'application/json']);
188+
}
189+
} catch (\Exception $e) {
190+
ll('Exception during file upload: ' . $e->getMessage());
191+
$data = json_encode(['error' => 'Exception during file upload : ' . $e->getMessage()]);
192+
return new GenericResponse(status: Status::INTERNAL_SERVER_ERROR, body: $data, headers: ['Content-Type' => 'application/json']);
193+
}
194+
}
195+
196+
#[Get('/admin/posts/create', middleware: [IsUserConnected::class, IsUserAdmin::class])]
197+
public function create(): View
198+
{
199+
return view('../Views/Admin/create.view.php');
200+
}
201+
202+
203+
function generateSlug(string $title): string
204+
{
205+
// Normalize accented characters to ASCII
206+
$title = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $title);
207+
208+
// remove all non-alphanumeric characters except spaces
209+
$title = preg_replace('/[^A-Za-z0-9\s]/', '', $title);
210+
211+
// Convert into lowercase
212+
$title = strtolower($title);
213+
214+
// Replace spaces with hyphens
215+
$slug = preg_replace('/\s+/', '-', $title);
216+
217+
// Remove multiple hyphens and trim hyphens from the start and end
218+
$slug = trim(preg_replace('/-+/', '-', $slug), '-');
219+
220+
return $slug;
221+
}
222+
223+
#[Post('/admin/posts', middleware: [IsUserConnected::class, IsUserAdmin::class])]
224+
public function storeNew(PostRequest $request): Redirect
225+
{
226+
ll("create post request", $request);
227+
// Get the form data
228+
$title = $request->get('title');
229+
$tldr = $request->get('tldr') ?? '';
230+
$markdownContent = ltrim($request->get('markdown'));
231+
$cover_image = $request->get('cover_image') ?? null;
232+
$published = $request->get('published') ?? false;
233+
234+
// Generate unique slug from title
235+
$slug = $this->generateSlug($title);
236+
// $slug = strtolower(preg_replace('/[^A-Za-z0-9]+/', '-', $title));
237+
$existingPost = $this->postRepository->findBySlug($slug);
238+
if ($existingPost) {
239+
$slug = $slug . '-' . time(); // add a timestamp to make it unique
240+
}
241+
242+
// Create a new Post object
243+
$post = new \Happytodev\Cyclone\Models\Post();
244+
$post->title = $title;
245+
$post->slug = $slug;
246+
$post->tldr = $tldr;
247+
$post->markdown_file_path = 'content/blog/' . $slug . '.md';
248+
$post->cover_image = $cover_image;
249+
$post->published = $published;
250+
$post->created_at = new \DateTimeImmutable();
251+
$post->published_at = $published ? new \DateTimeImmutable() : null;
252+
$post->user_id = $this->user?->id->id;
253+
$post->save();
254+
255+
// Build frontmatter
256+
$frontmatter = [
257+
'title' => $title,
258+
'slug' => $slug,
259+
'tldr' => $tldr,
260+
'markdown_file_path' => $post->markdown_file_path,
261+
'cover_image' => $cover_image,
262+
'published' => $published,
263+
];
264+
$frontmatterYaml = Yaml::dump($frontmatter);
265+
266+
// Combine frontmatter and Markdown content
267+
$fullContent = "---\n" . $frontmatterYaml . "---\n" . $markdownContent;
268+
269+
// Check and create the directory if necessary
270+
$dir = root_path() . '/content/blog';
271+
if (!is_dir($dir)) {
272+
mkdir($dir, 0755, true);
273+
}
274+
275+
// Write the Markdown file
276+
$markdownPath = $dir . '/' . $slug . '.md';
277+
file_put_contents($markdownPath, $fullContent);
278+
279+
// Redirect to the new post page
280+
return new Redirect(uri([BlogController::class, 'show'], slug: $post->slug));
281+
}
282+
283+
#[Post('/admin/posts/{slug}/delete', middleware: [IsUserConnected::class, IsUserAdmin::class])]
284+
public function destroy(string $slug): Redirect
285+
{
286+
$post = $this->postRepository->findBySlug($slug);
287+
if ($post) {
288+
// remove the associated markdown file
289+
$markdownPath = root_path() . '/' . $post->markdown_file_path;
290+
if (file_exists($markdownPath)) {
291+
unlink($markdownPath);
292+
}
293+
// remove the post from the database
294+
$post->delete();
295+
}
296+
// redirect to the posts index
297+
return new Redirect(uri([self::class, 'index']));
298+
}
299+
}

src/Middlewares/IsUserAdmin.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace Happytodev\Cyclone\Middlewares;
4+
5+
use App\Auth\User;
6+
use Tempest\Log\Logger;
7+
use Tempest\Http\Request;
8+
use Tempest\Core\Priority;
9+
use Tempest\Http\Response;
10+
use Tempest\Auth\Authenticator;
11+
use Tempest\Router\HttpMiddleware;
12+
use Tempest\Discovery\SkipDiscovery;
13+
use Tempest\Http\Responses\Redirect;
14+
use Tempest\Router\HttpMiddlewareCallable;
15+
use Happytodev\Cyclone\Controllers\AdminController;
16+
use Happytodev\Cyclone\Controllers\ErrorController;
17+
18+
use function Tempest\uri;
19+
20+
#[SkipDiscovery]
21+
#[Priority(Priority::HIGHEST - 1)]
22+
final class IsUserAdmin implements HttpMiddleware
23+
{
24+
public function __construct(
25+
private Authenticator $authenticator,
26+
private Logger $logger,
27+
) {
28+
}
29+
30+
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
31+
{
32+
ll('current user', $this->authenticator->currentUser());
33+
34+
// Check if a user is logged in
35+
if (!$this->authenticator->currentUser()) {
36+
$this->logger->info("User not connected");
37+
return new Redirect(uri([AdminController::class, 'showLogin']));
38+
}
39+
40+
$isAdmin = $this->authenticator->currentUser()->hasPermission('admin');
41+
42+
if (!$isAdmin) {
43+
$this->logger->info("User not admin");
44+
// redirect to error 403 page
45+
// return new Redirect(uri([ErrorController::class, 'error'], ['error' => '403']));
46+
return new Redirect(uri('/error/403'));
47+
}
48+
49+
$this->logger->info("User has role admin");
50+
51+
// If the user is logged in, continue
52+
return $next($request);
53+
}
54+
}

0 commit comments

Comments
 (0)