Dear hacker,
The other day I faced one interesting question…
It’s not something I had never thought about, but it’s interesting enough to write about, so here is what it was:
I had a zip archive containing my new free template Darky that I wanted to give to subscribers and customers only.
Any file in Next.Js’ public directory can be downloaded freely by anyone who knows its URL, right?
So I needed a way to give access to signed-in users only…
Well, it turned out there were several possible ways to achieve this in my use case, and the three most feasible were:
Put the file in a private folder and serve it with a custom endpoint
Hide the public bucket URL behind a custom endpoint
Generate a “signed URL” to a resource in a cloud storage
Let’s see exactly how we can do it with Next.js one at a time.
So this is the base case where you don’t use any cloud storage solution and just need a simple way to restrict access to the file.
The first step is to create a folder OUTSIDE the public one that will contain all the files you want to protect. You need to do this because if your important files are somewhere in the public folder, then anybody who knows their URL will be able to download them.
Even worse, their URLs can circulate around the web. People can share them on Twitter or Facebook. They can post them on their own websites or spread them out through email.
So say good bay to all the benefits you hoped for…
That’s why we put the protected files in a private folder, so they don’t have a public URL and can’t be accessed through an HTTP request.
The second step is to create a custom endpoint.
It could be something like this…
import {NextApiRequest, NextApiResponse} from "next";
import path from 'path';
import {createReadStream, existsSync} from 'fs';
import withSession from '@/lib/auth/session';
import WithSession from '@/lib/types/api/WithSession';
export const handler = async (req: WithSession<NextApiRequest>, res: NextApiResponse) => {
if (req.method !== 'GET') {
res.status(400).json({message: 'Not existing endpoint'})
return
}
//Check if the user is signed in
if (!req.session?.credentials?.userId) {
res.status(401).json({message: 'Access denied!'})
return
}
try {
const {filename} = req.query;
//If no file name, return 404
if (!filename || !process.env.PROTECTED_FILES_FOLDER) {
res.status(404).json({message: 'Not found'});
return;
}
const filePath = path.join(process.env.PROTECTED_FILES_FOLDER, filename as string);
if (!existsSync(filePath)) {
res.status(404).json({message: 'Not found'});
return;
}
//Set the proper headers
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename=${filename}`);
//Create a read stream and pipe to the response
createReadStream(filePath).pipe(res);
} catch (exception) {
//Conceal the exception, but log it
console.warn(exception)
res.status(500).json({message: 'Internal Server Error'});
}
}
export default withSession(handler);
Here it accepts the filename as a parameter. If the requested file doesn’t exist, it returns a response with the HTTP response code 404 - Not Found.
Before that, it checks if the user is signed in or has a valid JWT token, etc. It depends on the specific method you use to authenticate users.
Once it validates that the user is signed in, it creates a read stream and pipe it as a response.
In case the user is not signed in, it returns an HTTP response with code 401 - Unauthorized.
I’m using Google Cloud, so I keep my files in their Cloud Storage, where I can have private and public “buckets.”
Because the templates I wanted to give away are not so of a critical asset, I decided to go the easier way and protect them just enough so they are not freely accessible. At the same time, I didn’t spend a lot of time implementing complex solutions.
So I made a public bucket for all my templates, and I created the following API endpoint:
import {NextApiRequest, NextApiResponse} from "next";
import fetch from 'node-fetch';
import stream from 'stream';
import {promisify} from 'util';
import withSession from '@/lib/auth/session';
import WithSession from '@/lib/types/api/WithSession';
export const handler = async (req: WithSession<NextApiRequest>, res: NextApiResponse) => {
if (req.method !== 'GET') {
res.status(400).json({message: 'Not existing endpoint'})
return
}
//Check if the user is signed in
if (!req.session?.credentials?.userId) {
res.status(401).json({message: 'Access denied!'})
return
}
try {
const pipeline = promisify(stream.pipeline);
const {filename} = req.query;
const url = `https://storage.googleapis.com/some-bucket/${filename}`;
//Request the file
const fileRes = await fetch(url);
//If no file, return 404
if (!fileRes.ok) {
res.status(404).json({message: 'Not found'});
return;
}
//Set the proper headers
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename=${filename}`);
//Pipe the file data
await pipeline(fileRes.body, res);
} catch (exception) {
//Conceal the exception, but log it
console.warn(exception)
res.status(500).json({message: 'Internal Server Error'});
}
}
export default withSession(handler);
As you can see, it’s not very different from the first code snippet I showed you.
Here it makes all the mentioned checks, but instead of reading the file that resides in the local file system, it makes a GET request to Google Cloud Storage and then pipes the response to the client’s browser.
Simple, right?
But bear in mind that it’s not very secure.
My bucket is public, and if somebody somehow discovers its URL, they can download all of my templates at once without providing their email or signing in with their credentials.
This last way to protect a file is the most secure one.
Most cloud providers offer a mechanism to share unique URLs to files issued on a user basis. So basically, the user requests a link, you handle that request with some kind of js library, and then you return the result.
The result is a “signed URL” that allows the user to download the file and is valid for a certain amount of time. So after one hour or twenty-four hours (you choose the period), this signed URL is no longer good and displays an error when opened.
I dropped some links in the resource section if you need more info.
Well, that’s a tough question. It really depends on your specific use case and your desire to take risks.
If you offer something valuable for download, you want to put in place the highest protection you can come up with. There are bad people out there who make money by getting access to expensive stuff and later reselling them for peanuts. So you need protection.
If you offer to download something not so valuable and you’re ready to accept the risk, then maybe it’s not worth implementing a complex access control mechanism because it takes time and costs more money.
But no matter what, always find a way to protect your assets!
Be Happy,
Sashe Vuchkov,
Full-stack developer
Files offered for download to subscribers or customers should not be put in the public folder of Next.js because they will be freely accessible.
There are three feasible ways to protect a file, and which one you choose depends on the level of acceptable risk and how valuable the file is.
The core idea is to use an API endpoint that checks the user’s credentials and serves the protected file if everything is OK.
Also Published Here