In the previous article, we implemented a comment reply feature for our FastAPI blog, significantly enhancing the interactivity of the comments section.
Now, while the functionality for posts and comments is quite complete, the posts themselves only support plain text, which is a bit monotonous.
In this article, we will add an image upload feature to the posts, allowing your blog content to be rich with both text and images, making it more expressive.
The principle behind implementing image uploads is as follows:
- A user selects an image on the frontend page and uploads it.
- The backend receives the image and stores it in an object storage service.
- The backend returns a publicly accessible URL for the image.
- The frontend inserts this URL into the post's content text box in Markdown format (

). - When the post content is finally rendered as a webpage, the browser fetches the image using this URL and displays it.
Step 1: Prepare S3-Compatible Object Storage
First, we need a place to store the images uploaded by users. Although you could store them directly on the server's hard drive, in modern web applications, it's more recommended to use an object storage service (like AWS S3) because it's easier to maintain, more scalable, and more cost-effective.
For convenience, we will continue to use Leapcell, which not only provides a database and backend hosting but also offers an S3-compatible Object Storage service.
Log in to the Leapcell main interface and click "Create Object Storage".
Fill in a name to create the Object Storage.
On the Object Storage details page, you can see the connection parameters like Endpoint, Access Key ID, and Secret Access Key. We will use these in our backend configuration later.
The interface also provides a very convenient UI for uploading and managing files directly in the browser.
Step 2: Implement the Image Upload API on the Backend
Next, let's build the FastAPI backend to handle file uploads.
1. Install Dependencies
We need boto3
(the AWS SDK for Python) to upload files to an S3-compatible object storage service. Additionally, we need a Markdown parsing library to convert Markdown format to HTML when displaying posts. Here, we'll choose markdown2
.
Add them to your requirements.txt
file:
# requirements.txt
# ... other packages
boto3
markdown2
Then run the installation command:
pip install -r requirements.txt
2. Create an Upload Service
To keep our code clean, we'll create a new service file for the file upload functionality.
Create a new file uploads_service.py
in the project's root directory. This service will be responsible for the core logic of communicating with S3.
# uploads_service.py
import boto3
import uuid
from fastapi import UploadFile
# --- S3 Configuration ---
# It is recommended to read these values from environment variables
S3_ENDPOINT_URL = "https://objstorage.leapcell.io"
S3_ACCESS_KEY_ID = "YOUR_ACCESS_KEY_ID"
S3_SECRET_ACCESS_KEY = "YOUR_SECRET_ACCESS_KEY"
S3_BUCKET_NAME = "my-fastapi-blog-images" # Your Bucket name
S3_PUBLIC_URL = f"https://{S3_BUCKET_NAME}.leapcellobj.com" # Your Bucket's public access URL
# Initialize S3 client
s3_client = boto3.client(
"s3",
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
region_name="us-east-1", # For S3-compatible storage, the region is often nominal
)
def upload_file_to_s3(file: UploadFile) -> str:
"""
Uploads a file to S3 and returns its public URL.
"""
try:
# Generate a unique filename to avoid conflicts
file_extension = file.filename.split(".")[-1]
unique_filename = f"{uuid.uuid4()}.{file_extension}"
s3_client.upload_fileobj(
file.file, # a file-like object
S3_BUCKET_NAME,
unique_filename,
ExtraArgs={
"ContentType": file.content_type,
"ACL": "public-read", # Set the file to be publicly readable
},
)
# Return the public URL of the file
return f"{S3_PUBLIC_URL}/{unique_filename}"
except Exception as e:
print(f"Error uploading to S3: {e}")
raise
Note: To simplify the implementation, the S3 connection parameters are hardcoded. In a real project, it is strongly recommended to store this sensitive information in environment variables and read it using os.getenv()
.
3. Create an Upload Route
Now, let's create a new file uploads.py
in the routers
folder to define the API route.
# routers/uploads.py
from fastapi import APIRouter, Depends, UploadFile, File
from auth_dependencies import login_required
import uploads_service
router = APIRouter()
@router.post("/uploads/image")
def upload_image(
user: dict = Depends(login_required), # Only logged-in users can upload
file: UploadFile = File(...)
):
"""
Receives an uploaded image file, uploads it to S3, and returns the URL.
"""
url = uploads_service.upload_file_to_s3(file)
return {"url": url}
Finally, mount this new router module in the main application main.py
.
# main.py
# ... other imports
from routers import posts, users, auth, comments, uploads # import the uploads router
# ...
app = FastAPI(lifespan=lifespan)
# ...
# Include routers
app.include_router(posts.router)
app.include_router(users.router)
app.include_router(auth.router)
app.include_router(comments.router)
app.include_router(uploads.router) # mount the uploads router
Step 3: Integrate the FilePicker API on the Frontend
With the backend ready, let's modify the new-post.html
frontend page to add the upload functionality.
There are two ways to handle uploads: the modern FilePicker API and the traditional <input type="file">
.
Traditional method: <input type="file">
has excellent compatibility and is supported by all browsers. However, its API is somewhat outdated, not very intuitive, and provides a poor user experience.
Modern method: The File System Access API is easier to use, more powerful, and can lead to a better user experience. However, its compatibility is not as good as the traditional method, and it must be run in a secure context (HTTPS).
Considering our blog is a modern project, we will use the FilePicker API to implement file uploads.
Open templates/new-post.html
and add a toolbar and an "Upload Image" button above the textarea
.
{% include "_header.html" %}
<form action="/posts" method="POST" class="post-form">
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" required />
</div>
<div class="form-group">
<label for="content">Content</label>
<div class="toolbar">
<button type="button" id="upload-image-btn">Upload Image</button>
</div>
<textarea id="content" name="content" rows="10" required></textarea>
</div>
<button type="submit">Submit</button>
</form>
<script>
document.addEventListener("DOMContentLoaded", () => {
const uploadBtn = document.getElementById("upload-image-btn");
const contentTextarea = document.getElementById("content");
uploadBtn.addEventListener("click", async () => {
try {
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: "Images",
accept: { "image/*": [".png", ".jpeg", ".jpg", ".gif", ".webp"] },
},
],
});
const file = await fileHandle.getFile();
uploadFile(file);
} catch (error) {
// An AbortError is thrown when the user cancels file selection, we ignore it
if (error.name !== "AbortError") {
console.error("FilePicker Error:", error);
}
}
});
function uploadFile(file) {
if (!file) return;
const formData = new FormData();
formData.append("file", file);
// Display a simple loading indicator
uploadBtn.disabled = true;
uploadBtn.innerText = "Uploading...";
fetch("/uploads/image", {
method: "POST",
body: formData,
// Note: You don't need to manually set the Content-Type header when using FormData
})
.then((response) => response.json())
.then((data) => {
if (data.url) {
// Insert the returned image URL into the textbox in Markdown format
const markdownImage = ``;
insertAtCursor(contentTextarea, markdownImage);
} else {
alert("Upload failed. Please try again.");
}
})
.catch((error) => {
console.error("Upload Error:", error);
alert("An error occurred during upload.");
})
.finally(() => {
uploadBtn.disabled = false;
uploadBtn.innerText = "Upload Image";
});
}
// Helper function to insert text at the 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.html" %}
Step 4: Render Posts Containing Images
We have successfully inserted the Markdown link for the image into the post's content, but it still just renders as a string of text. We need to convert the Markdown format into actual HTML on the post detail page post.html
.
1. Parse Markdown in the Route
Modify the get_post_by_id
function in routers/posts.py
. Before passing the post data to the template, parse its content with markdown2
.
# routers/posts.py
# ... other imports
import markdown2 # import markdown2
# ...
@router.get("/posts/{post_id}", response_class=HTMLResponse)
def get_post_by_id(
request: Request,
post_id: uuid.UUID,
session: Session = Depends(get_session),
user: dict | None = Depends(get_user_from_session),
):
post = session.get(Post, post_id)
comments = comments_service.get_comments_by_post_id(post_id, session)
# Parse Markdown content
if post:
post.content = markdown2.markdown(post.content)
return templates.TemplateResponse(
"post.html",
{
"request": request,
"post": post,
"title": post.title,
"user": user,
"comments": comments,
},
)
2. Update the Post Detail Page View
Finally, modify templates/post.html
to ensure it correctly renders the parsed HTML. Previously, we used {{ post.content | replace('\n', '<br>') | safe }}
to handle line breaks. Now, since the content is already HTML, we just need to use the safe
filter.
{# templates/post.html #}
{# ... #}
<article class="post-detail">
<h1>{{ post.title }}</h1>
<small>{{ post.createdAt.strftime('%Y-%m-%d') }}</small>
<div class="post-content">{{ post.content | safe }}</div>
</article>
{# ... #}
The safe
filter tells Jinja2 that the content of this variable is safe and does not need HTML escaping, which allows the image <img>
tag and other Markdown formatting to be rendered correctly.
Run and Test
Now, restart your application:
uvicorn main:app --reload
After logging in, go to the "New Post" page, and you will see the new "Upload Image" button. Click it to select a file for upload.
Select an image. After the upload is complete, the Markdown link for the image will be automatically inserted into the text box.
Publish the post and go to the post detail page. You will see that the image is successfully rendered. And as an added bonus, the post content now supports Markdown syntax!
Congratulations, your blog now supports image uploads (and Markdown)! From now on, your blog will surely be much more exciting.
Follow us on X: @LeapcellHQ
Related Posts:
Top comments (0)