Einen großartigen Nest.js Blog erstellen: Bilder hochladen
Daniel Hayes
Full-Stack Engineer · Leapcell

Im vorherigen Tutorial haben wir eine Kommentar-Antwort-Funktion für unseren Blog implementiert.
Jetzt, wo die Artikel-Kommentarfunktion ziemlich vollständig ist, wirken die Artikel selbst etwas schlicht – schließlich unterstützen sie nur einfachen Text.
Im folgenden Tutorial werden wir es ermöglichen, dass Artikel Bilder einfügen können, um ihre Ausdruckskraft zu bereichern.
Das Prinzip hinter dem Einfügen von Bildern ist wie folgt:
- Der Benutzer wählt ein Bild aus und lädt es zum Backend hoch.
- Das Backend speichert das Bild irgendwo und gibt eine URL zum Zugriff auf die Bildressource zurück.
- Das Frontend fügt die Bild-URL in den Artikelinhalt ein.
- Der Artikelinhalt wird schließlich als Webseite gerendert, und das Bild wird angezeigt, indem die entsprechenden Daten basierend auf der Bild-URL abgerufen werden.
Schritt 1: S3-kompatiblen Objektspeicher vorbereiten
Bevor wir mit dem Programmieren beginnen, brauchen wir einen Ort, um die hochgeladenen Bilder zu speichern. Das Speichern von Bildern direkt auf der Festplatte des Servers ist eine Methode, aber in modernen Anwendungen wird die Verwendung eines Objektspeicher-Dienstes (wie AWS S3) aufgrund seiner Vorteile wie hoher Verfügbarkeit, einfacher Skalierbarkeit und geringer Kosten empfohlen.
Der Einfachheit halber werden wir weiterhin Leapcell verwenden, das nicht nur eine Datenbank und Backend-Hosting bietet, sondern auch einen S3-kompatiblen Objektspeicher-Dienst anbietet.
- Bucket erstellen: Melden Sie sich in der Leapcell-Konsole an, gehen Sie zur Seite "Object Storage" und klicken Sie auf "Create Bucket". Geben Sie einen global eindeutigen Bucket-Namen (z. B.
my-nest-blog-images
) ein und wählen Sie eine Region aus. - Zugangsdaten abrufen: Auf der Bucket-Detailseite oder in Ihren Kontoeinstellungen finden Sie Ihren Access Key ID und Secret Access Key. Notieren Sie sich auch Ihre Endpoint-Adresse.
Diese Informationen (Bucket-Name, Endpoint, Access Key, Secret Key) sind entscheidend und werden wir später in unserer Backend-Konfiguration verwenden.
Schritt 2: Die API für den Bildupload im Backend implementieren
Nun erstellen wir das Nest.js-Backend zur Verarbeitung von Datei-Uploads.
1. Abhängigkeiten installieren
Wir benötigen aws-sdk
(die neue Version ist @aws-sdk/client-s3
), um mit S3-kompatiblen Speicherdiensten zu interagieren, und @nestjs/platform-multer
zur Verarbeitung von multipart/form-data
-Anfragen.
npm install @aws-sdk/client-s3 npm install @nestjs/platform-multer
2. Umgebungsvariablen konfigurieren
Aus Sicherheitsgründen sollten Sie Ihre S3-Zugangsdaten nicht direkt in Ihren Code einbetten. Erstellen Sie eine .env
-Datei im Stammverzeichnis des Projekts (falls Sie noch keine haben) und fügen Sie den folgenden Inhalt hinzu:
# .env S3_ENDPOINT=https://objects.leapcell.io S3_ACCESS_KEY_ID=IHRE_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY=IHR_SECRET_ACCESS_KEY S3_BUCKET_NAME=my-nest-blog-images
Stellen Sie sicher, dass Sie die obigen Werte durch Ihre eigenen Informationen ersetzen.
Damit Nest.js die .env
-Datei lesen kann, müssen wir das Config-Modul installieren:
npm install @nestjs/config
Importieren Sie dann ConfigModule
in app.module.ts
:
// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; // ... andere Imports @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), // Als globales Modul festlegen // ... andere Module ], // ... }) export class AppModule {}
3. Das Uploads-Modul erstellen
Wir erstellen ein separates Modul für unsere Datei-Upload-Funktionalität.
nest generate module uploads nest generate controller uploads nest generate service uploads
Schreiben Sie uploads.service.ts
Dieser Dienst ist für die Kernlogik der Kommunikation mit S3 verantwortlich.
// src/uploads/uploads.service.ts import { Injectable } from '@nestjs/common'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { ConfigService } from '@nestjs/config'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class UploadsService { private readonly s3: S3Client; constructor(private readonly configService: ConfigService) { this.s3 = new S3Client({ endpoint: this.configService.get<string>('S3_ENDPOINT'), region: 'us-east-1', // Für S3-kompatiblen Speicher ist die Region oft nur ein Formalismus credentials: { accessKeyId: this.configService.get<string>('S3_ACCESS_KEY_ID'), secretAccessKey: this.configService.get<string>('S3_SECRET_ACCESS_KEY'), }, }); } async uploadFile(file: Express.Multer.File): Promise<string> { const bucket = this.configService.get<string>('S3_BUCKET_NAME'); const endpoint = this.configService.get<string>('S3_ENDPOINT'); const uniqueFileName = `${uuidv4()}-${file.originalname}`; const command = new PutObjectCommand({ Bucket: bucket, Key: uniqueFileName, Body: file.buffer, ContentType: file.mimetype, ACL: 'public-read', // Die Datei als öffentlich lesbar festlegen }); try { await this.s3.send(command); // Die öffentliche URL der Datei zurückgeben return `${endpoint}/${bucket}/${uniqueFileName}`; } catch (error) { console.error('Fehler beim Hochladen nach S3:', error); throw new Error('Der Datei-Upload ist fehlgeschlagen.'); } } }
Schreiben Sie uploads.controller.ts
Dieser Controller definiert die API-Route und verwendet FileInterceptor
, um die hochgeladene Datei zu empfangen.
// src/uploads/uploads.controller.ts import { Controller, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { UploadsService } from './uploads.service'; import { AuthenticatedGuard } from '../auth/authenticated.guard'; @Controller('uploads') export class UploadsController { constructor(private readonly uploadsService: UploadsService) {} @UseGuards(AuthenticatedGuard) // Nur eingeloggte Benutzer können hochladen @Post('image') @UseInterceptors(FileInterceptor('file')) // 'file' ist der Name des Dateifeldes im Formular async uploadImage(@UploadedFile() file: Express.Multer.File) { const url = await this.uploadsService.uploadFile(file); return { url }; } }
Importieren Sie schließlich UploadsModule
in app.module.ts
, damit die Anwendung dieses neue Modul erkennt.
Schritt 3: Integration des Frontends mit der FilePicker-API
Jetzt, wo das Backend bereit ist, passen wir die Frontend-Seite new-post.ejs
an, um die Upload-Funktionalität hinzuzufügen.
FilePicker API im Vergleich zur traditionellen <input type="file">
Bevor wir beginnen, vergleichen wir kurz zwei Frontend-Methoden zur Dateiauswahl:
-
Traditionelle Methode:
<input type="file">
- Vorteile: Hervorragende Kompatibilität, wird von allen Browsern unterstützt, einfache Implementierung.
- Nachteile: Das UI-Design wird vom Browser bestimmt und kann nicht angepasst werden, was zu einer eher primitiven und veralteten Benutzererfahrung führt.
-
Moderne Methode: File System Access API (
window.showOpenFilePicker
)- Vorteile: Bietet einen modernen System-Dateiauswahldialog, eine leistungsfähigere API (z. B. kann Dateihandles erhalten, sich das zuletzt geöffnete Verzeichnis merken) und eine Benutzererfahrung, die nativen Anwendungen näher kommt.
- Nachteile:
- Kompatibilitätsprobleme: Derzeit hauptsächlich in Chromium-basierten Browsern (Chrome, Edge) unterstützt. Firefox und Safari unterstützen es noch nicht.
- Sicherheitsbeschränkungen: Muss in einem sicheren Kontext (HTTPS) ausgeführt werden.
Da unser Blog ein modernes Projekt ist, werden wir showOpenFilePicker
bevorzugt verwenden und können bei Bedarf eine Fallback-Lösung anbieten.
1. new-post.ejs
aktualisieren
Fügen wir in views/new-post.ejs
neben der <textarea>
einen Button "Upload Image" hinzu.
<%- include('_header', { title: 'New Post' }) %> <form action="/posts" method="POST" class="post-form"> <div class="form-group"> <label for="title">Titel</label> <input type="text" id="title" name="title" required /> </div> <div class="form-group"> <label for="content">Inhalt</label> <div class="toolbar"> <button type="button" id="upload-image-btn">Bild hochladen</button> </div> <textarea id="content" name="content" rows="15" required></textarea> </div> <button type="submit">Absenden</button> </form> <script> document.addEventListener('DOMContentLoaded', () => { const uploadBtn = document.getElementById('upload-image-btn'); const contentTextarea = document.getElementById('content'); uploadBtn.addEventListener('click', async () => { // Prüfen, ob der Browser die FilePicker-API unterstützt if (window.showOpenFilePicker) { try { const [fileHandle] = await window.showOpenFilePicker({ types: [{ description: 'Bilder', accept: { 'image/*': ['.png', '.jpeg', '.jpg', '.gif', '.webp'] }, }], }); const file = await fileHandle.getFile(); uploadFile(file); } catch (error) { // Ein AbortError wird ausgelöst, wenn der Benutzer die Dateiauswahl abbricht; wir ignorieren ihn if (error.name !== 'AbortError') { console.error('FilePicker Error:', error); } } } else { // Fallback: Für nicht unterstützte Browser können wir ein verstecktes Eingabefeld erstellen, um es auszulösen alert('Ihr Browser unterstützt die moderne FilePicker-API nicht.'); // const input = document.createElement('input'); // input.type = 'file'; // input.accept = 'image/*'; // input.onchange = (e) => uploadFile(e.target.files[0]); // input.click(); } }); function uploadFile(file) { if (!file) return; const formData = new FormData(); formData.append('file', file); // Eine einfache Ladeanzeige anzeigen uploadBtn.disabled = true; uploadBtn.innerText = 'Uploading...'; fetch('/uploads/image', { method: 'POST', body: formData, // Hinweis: Die Content-Type-Header-Einstellung ist nicht erforderlich, wenn FormData verwendet wird }) .then(response => response.json()) .then(data => { if (data.url) { // Die zurückgegebene Bild-URL in das Textfeld einfügen const markdownImage = ``; insertAtCursor(contentTextarea, markdownImage); } else { alert('Der Upload ist fehlgeschlagen. Bitte versuchen Sie es erneut.'); } }) .catch(error => { console.error('Upload Error:', error); alert('Während des Uploads ist ein Fehler aufgetreten.'); }) .finally(() => { uploadBtn.disabled = false; uploadBtn.innerText = 'Bild hochladen'; }); } // Hilfsfunktion zum Einfügen von Text an der Cursor-Position function insertAtCursor(myField, myValue) { if (myField.selectionStart || myField.selectionStart === 0) { var startPos = myField.selectionStart; var endPos = myField.selectionEnd; myField.value = myField.value.substring(0, startPos) + myValue + myField.value.substring(endPos, myField.value.length); myField.selectionStart = startPos + myValue.length; myField.selectionEnd = startPos + myValue.length; } else { myField.value += myValue; } } }); </script> <%- include('_footer') %>
Schritt 4: Artikel mit Bildern rendern
Wir haben den Markdown-Link des Bildes erfolgreich in die Datenbank eingefügt, aber er wird immer noch als Textzeichenkette angezeigt. Wir müssen das Markdown-Format in HTML auf der Artikeldetailseite, post.ejs
, umwandeln.
1. Eine Markdown-Parsing-Bibliothek installieren
Wir verwenden marked
, eine beliebte und effiziente Bibliothek, um das Parsen im Backend zu übernehmen.
npm install marked npm install -D @types/marked
2. Markdown im Controller parsen
Modifizieren Sie die findOne
-Methode in src/posts/posts.controller.ts
. Bevor Sie die Post-Daten an das Template übergeben, parsen Sie deren Inhalt mit marked
.
// src/posts/posts.controller.ts import { Controller, Get, Render, Param, Request } from '@nestjs/common'; import { PostsService } from './posts.service'; import { CommentsService } from '../comments/comments.service'; import { marked } from 'marked'; // marked importieren @Controller('posts') export class PostsController { constructor( private readonly postsService: PostsService, private readonly commentsService: CommentsService ) {} // ... weitere Methoden @Get(':id') @Render('post') async findOne(@Param('id') id: string, @Request() req) { const post = await this.postsService.findOne(id); const comments = await this.commentsService.findByPostId(id); // Markdown-Inhalt parsen if (post) { post.content = marked.parse(post.content) as string; } return { post, user: req.user, comments }; } }
3. Die post.ejs
-Ansicht aktualisieren
Aktualisieren Sie schließlich views/post.ejs
, um sicherzustellen, dass es den geparsten HTML-Code korrekt rendert. In der vorherigen Version haben wir <%- post.content.replace(/ /g, '<br />') %>
verwendet, um Zeilenumbrüche zu handhaben. Da der Inhalt jetzt bereits HTML ist, können wir ihn direkt ausgeben.
<%- include('_header', { title: post.title }) %> <article class="post-detail"> <h1><%= post.title %></h1> <small><%= new Date(post.createdAt).toLocaleDateString() %></small> <div class="post-content"><%- post.content %></div> </article> <a href="/" class="back-link">← Zurück zur Übersicht</a> <%- include('_footer') %>
Beachten Sie, dass wir <%-
anstelle von <%=
verwenden. Ersteres gibt rohes HTML aus, letzteres maskiert es.
Ausführen und Testen
Starten Sie nun Ihre Anwendung neu mit npm run start:dev
und dann:
- Melden Sie sich an und gehen Sie zur Seite "Neuer Beitrag".
- Klicken Sie auf den Button "Bild hochladen", und es öffnet sich ein moderner Dateiauswahldialog.
- Wählen Sie ein Bild aus. Nach Abschluss des Uploads wird der Markdown-Link des Bildes automatisch in das Textfeld eingefügt.
- Veröffentlichen Sie den Artikel.
- Gehen Sie zur Detailseite des Artikels, und Sie sehen das erfolgreich gerenderte Bild.
Herzlichen Glückwunsch, Ihr Blog unterstützt jetzt Bild-Uploads! Von nun an wird Ihr Blog sicherlich viel spannender sein.