How to cache data publicly from firebase function? - firebase

I'm a little bit lost on how firebase functions work with authentication,
Suppose I have a function that pulls 100 documents and sets the cache header for 24 hours.
res.set('Cache-Control', 'public, max-age=0, s-maxage=86400' // 24 * 60 * 60
By default, does that apply to all users or is it cached per user? There's some instances where the 100 documents are unique to the user - while other functions where the 100 documents are available to any user that's authenticated.
I see in the docs that you can set a __session which implies it's for individual users data, however there isn't much documentation for how to set that (or where). Is it set by default?
My goal is to have a function that requires the user be authenticated, then return 100 documents from a non-user specific collection - aka not have to read 100 documents per user. However, I don't think thats feasible because it would need to check if each user is authorized (not cacheable). So is there a way to just make a publicly available cache?
Any light that can be shared on this is greatly appreciated!

The Cache-Control header is used to instruct a user's browser and any CDN edge server on how to cache the request.
For requests requiring authentication, making use of the CDN is not really possible for this as you should be using Cache-Control: private for these responses (the default for Cloud Functions).
While you could check that your users are authenticated and then redirect them to a publically cached resource (like https://example.com/api/docs?sig=<somesignature>), this URL would still be accessible if someone got hold of that URL/cached data.
Arguably the best approach would be to store your "cached" responses in a single Cloud Firestore document (if it is less than 1MB in size and is JSON-compatible) or store it in Cloud Storage.
The code included below is an example of how you could do this with a Cloud Firestore cache. I've used posts where the authenticated user is the author as an example, but for this specific use case, you would be better off using the Firebase SDK to make such a query (realtime updates, finer control, query API). A similar approach could be applied for "all user" resources.
If attempting to cache HTML or some other not JSON friendly format, I would recommend changing the caching layer to Cloud Storage. Instead of storing the post's data in the cache entry, store the path and bucket to the cached file in storage (like below). Then if it hasn't expired, get a stream of that file from storage and pipe it through to the client.
{
data: {
fullPath: `/_serverCache/apiCache/${uid}/posts.html`,
bucket: "myBucket"
},
/* ... */
}
Common Example Code
import functions from "firebase-functions";
import { HttpsError } from "firebase-functions/lib/providers/https";
import admin from "firebase-admin";
import hash from "object-hash";
admin.initializeApp();
interface AttachmentData {
/** May contain a URL to the resource */
url?: string;
/** May contain Base64 encoded data of resource */
data?: string;
/** Type of this resource */
type: "image" | "video" | "social" | "web";
}
interface PostData {
author: string;
title: string;
content: string;
attachments: Record<string, AttachmentData>;
postId: string;
}
interface CacheEntry<T = admin.firestore.DocumentData> {
/** Time data was cached, as a Cloud Firestore Timestamp object */
cachedAt: admin.firestore.Timestamp;
/** Time data was cached, as a Cloud Firestore Timestamp object */
expiresAt: admin.firestore.Timestamp;
/** The ETag signature of the cached resource */
eTag: string;
/** The cached resource */
data: T;
}
/**
* Returns posts authored by this user as an array, from Firestore
*/
async function getLivePostsForAuthor(uid: string) {
// fetch the data
const posts = await admin.firestore()
.collection('posts')
.where('author', '==', uid)
.limit(100)
.get();
// flatten the results into an array, including the post's document ID in the data
const results: PostData[] = [];
posts.forEach((postDoc) => {
results.push({ postId: postDoc.id, ...postDoc.data() } as PostData);
});
return results;
}
/**
* Returns posts authored by this user as an array, caching the result from Firestore
*/
async function getCachedPostsForAuthor(uid: string) {
// Get the reference to the data's location
const cachedPostsRef = admin.firestore()
.doc(`_server/apiCache/${uid}/posts`) as admin.firestore.DocumentReference<CacheEntry<PostData[]>>;
// Get the cache entry's data
const cachedPostsSnapshot = await cachedPostsRef.get();
if (cachedPostsSnapshot.exists) {
// get the expiresAt property on it's own
// this allows us to skip processing the entire document until needed
const expiresAt = cachedPostsSnapshot.get("expiresAt") as CacheEntry["expiresAt"] | undefined;
if (expiresAt !== undefined && expiresAt.toMillis() > Date.now() - 60000) {
// return the entire cache entry as-is
return cachedPostsSnapshot.data()!;
}
}
// if here, the cache entry doesn't exist or has expired
// get the live results from Firestore
const results = await getLivePostsForAuthor(uid);
// etag, cachedAt and expiresAt are used for the HTTP cache-related headers
// only expiresAt is used when determining expiry
const cacheEntry: CacheEntry<PostData[]> = {
data: results,
eTag: hash(results),
cachedAt: admin.firestore.Timestamp.now(),
// set expiry as 1 day from now
expiresAt: admin.firestore.Timestamp.fromMillis(Date.now() + 86400000),
};
// save the cached data and it's metadata for future calls
await cachedPostsRef.set(cacheEntry);
// return the cached data
return cacheEntry;
}
HTTPS Request Function
This is the request type you would use for serving Cloud Functions behind Firebase Hosting. Unfortunately the implementation details aren't as straightforward as using a Callable Function (see below) but is provided as an official project sample. You will need to insert validateFirebaseIdToken() from that example for this code to work.
import express from "express";
import cookieParserLib from "cookie-parser";
import corsLib from "cors";
interface AuthenticatedRequest extends express.Request {
user: admin.auth.DecodedIdToken
}
const cookieParser = cookieParserLib();
const cors = corsLib({origin: true});
const app = express();
// insert from https://github.com/firebase/functions-samples/blob/2531d6d1bd6b16927acbe3ec54d40369ce7488a6/authorized-https-endpoint/functions/index.js#L26-L69
const validateFirebaseIdToken = /* ... */
app.use(cors);
app.use(cookieParser);
app.use(validateFirebaseIdToken);
app.get('/', async (req, res) => {
// if here, user has already been validated, decoded and attached as req.user
const user = (req as AuthenticatedRequest).user;
try {
const cacheEntry = await getCachedPostsForAuthor(user.uid);
// set caching headers
res
.header("Cache-Control", "private")
.header("ETag", cacheEntry.eTag)
.header("Expires", cacheEntry.expiresAt.toDate().toUTCString());
if (req.header("If-None-Match") === cacheEntry.eTag) {
// cached data is the same, just return empty 304 response
res.status(304).send();
} else {
// send the data back to the client as JSON
res.json(cacheEntry.data);
}
} catch (err) {
if (err instanceof HttpsError) {
throw err;
} else {
throw new HttpsError("unknown", err && err.message, err);
}
}
});
export const getMyPosts = functions.https.onRequest(app);
Callable HTTPS Function
If you are making use of the client SDKs, you can also request the cached data using Callable Functions.
This allows you to export the function like this:
export const getMyPosts = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
'failed-precondition',
'The function must be called while authenticated.'
);
}
try {
const cacheEntry = await getCachedPostsForAuthor(context.auth.uid);
return cacheEntry.data;
} catch (err) {
if (err instanceof HttpsError) {
throw err;
} else {
throw new HttpsError("unknown", err && err.message, err);
}
}
});
and call it from the client using:
const getMyPosts = firebase.functions().httpsCallable('getMyPosts');
getMyPosts()
.then((postsArray) => {
// do something
})
.catch((error) => {
// handle errors
})

Related

How to use secret key for on-demand revalidation for ISR (Next.js) in the frontend without exposing it?

According to the documentation, you should use a SECRET_TOKEN to prevent unauthorized access to your revalidate API route i.e.
https://<your-site.com>/api/revalidate?secret=<token>
But how are you supposed to call that route from the frontend and keep the token secret?
For example, if you have a simple POST that you then want to trigger the revalidate off of, you'd have to expose your secret token via NEXT_PUBLIC to be able to use it:
function handleSubmit(payload) {
axios.post(POST_URL, payload)
.then(() => {
axios.get(`/api/revalidate?secret=${process.env.NEXT_PUBLIC_SECRET_TOKEN}`)
})
.then(() => {
// redirect to on-demand revalidated page
})
}
What am I missing here? How can you call the API route through the frontend without exposing the SECRET_TOKEN?
I've been trying out On-Demand ISR and stumbled on a similar problem. I was trying to revalidate data after CRUD actions from my Admin dashboard living on the client, behind protected routes ("/admin/...").
If you have an authentication process setup and you're using Next-Auth's JWT strategy, it gives you access to the getToken() method, which decrypts the JWT of the current authenticated user.
You can then use whatever information you have passed through your callbacks to validate the request instead of relying on a SECRET_TOKEN.
import type { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
const secret = process.env.NEXTAUTH_SECRET;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const user = await getToken({ req, secret });
if (!user || user.role !== "ADMIN") {
return res.status(401).json({ message: "Revalidation not authorized"});
}
try {
// unstable_revalidate is being used in Next 12.1
// I'm passing the revalidation url through the query params
await res.unstable_revalidate(req.query.url as string);
return res.json({ revalidated: true });
} catch (err) {
return res.status(500).send("Error revalidating");
}
}
The Next.js video demo don't actually use a SECRET_KEY.
https://www.youtube.com/watch?v=BGexHR1tuOA
So I guess I'll just have to omit it and hope nobody abuses the revalidate API?
I think you need to create one file called ".env".
Inside the file, you put the params .env like this:
NEXT_PUBLIC_SECRET_TOKEN=123password
You must install the dependency dotenv:
npm i dotenv
and then you can call inside your function like this
function handleSubmit(payload) {
axios.post(POST_URL, payload)
.then(() => {
axios.get(`/api/revalidate?secret=${process.env.NEXT_PUBLIC_SECRET_TOKEN}`)
})
.then(() => {
// redirect to on-demand revalidated page
})
}

Am I doing Firestore Transactions correct?

I've followed the Firestore documentation with relation to transactions, and I think I have it all sorted correctly, but in testing I am noticing issues with my documents not getting updated properly sometimes. It is possible that multiple versions of the document could be submitted to the function in a very short interval, but I am only interested in only ever keeping the most recent version.
My general logic is this:
New/Updated document is sent to cloud function
Check if document already exists in Firestore, and if not, add it.
If it does exist, check that it is "newer" than the instance in firestore, if it is, update it.
Otherwise, don't do anything.
Here is the code from my function that attempts to accomplish this...I would love some feedback if this is correct/best way to do this:
const ocsFlight = req.body;
const procFlight = processOcsFlightEvent(ocsFlight);
try {
const ocsFlightRef = db.collection(collection).doc(procFlight.fltId);
const originalFlight = await ocsFlightRef.get();
if (!originalFlight.exists) {
const response = await ocsFlightRef.set(procFlight);
console.log("Record Added: ", JSON.stringify(procFlight));
res.status(201).json(response); // 201 - Created
return;
}
await db.runTransaction(async (t) => {
const doc = await t.get(ocsFlightRef);
const flightDoc = doc.data();
if (flightDoc.recordModified <= procFlight.recordModified) {
t.update(ocsFlightRef, procFlight);
console.log("Record Updated: ", JSON.stringify(procFlight));
res.status(200).json("Record Updated");
return;
}
console.log("Record isn't newer, nothing changed.");
console.log("Record:", JSON.stringify("Same Flight:", JSON.stringify(procFlight)));
res.status(200).json("Record isn't newer, nothing done.");
return;
});
} catch (error) {
console.log("Error:", JSON.stringify(error));
res.status(500).json(error.message);
}
The Bugs
First, you are trusting the value of req.body to be of the correct shape. If you don't already have type assertions that mirror your security rules for /collection/someFlightId in processOcsFlightEvent, you should add them. This is important because any database operations from the Admin SDKs will bypass your security rules.
The next bug is sending a response to your function inside the transaction. Once you send a response back the client, your function is marked inactive - resources are severely throttled and any network requests may not complete or crash. As a transaction may be retried a handful of times if a database collision is detected, you should make sure to only respond to the client once the transaction has properly completed.
You use set to write the new flight to Firestore, this can lead to trouble when working with transactions as a set operation will cancel all pending transactions at that location. If two function instances are fighting over the same flight ID, this will lead to the problem where the wrong data can be written to the database.
In your current code, you return the result of the ocsFlightRef.set() operation to the client as the body of the HTTP 201 Created response. As the result of the DocumentReference#set() is a WriteResult object, you'll need to properly serialize it if you want to return it to the client and even then, I don't think it will be useful as you don't seem to use it for the other response types. Instead, a HTTP 201 Created response normally includes where the resource was written to as the Location header with no body, but here we'll pass the path in the body. If you start using multiple database instances, including the relevant database may also be useful.
Fixing
The correct way to achieve the desired result would be to do the entire read->check->write process inside of a transaction and only once the transaction has completed, then respond to the client.
So we can send the appropriate response to the client, we can use the return value of the transaction to pass data out of it. We'll pass the type of the change we made ("created" | "updated" | "aborted") and the recordModified value of what was stored in the database. We'll return these along with the resource's path and an appropriate message.
In the case of an error, we'll return a message to show the user as message and the error's Firebase error code (if available) or general message as the error property.
// if not using express to wrangle requests, assert the correct method
if (req.method !== "POST") {
console.log(`Denied ${req.method} request`);
res.status(405) // 405 - Method Not Allowed
.set("Allow", "POST")
.end();
return;
}
const ocsFlight = req.body;
try {
// process AND type check `ocsFlight`
const procFlight = processOcsFlightEvent(ocsFlight);
const ocsFlightRef = db.collection(collection).doc(procFlight.fltId);
const { changeType, recordModified } = await db.runTransaction(async (t) => {
const flightDoc = await t.get(ocsFlightRef);
if (!flightDoc.exists) {
t.set(ocsFlightRef, procFlight);
return {
changeType: "created",
recordModified: procFlight.recordModified
};
}
// only parse the field we need rather than everything
const storedRecordModified = flightDoc.get('recordModified');
if (storedRecordModified <= procFlight.recordModified) {
t.update(ocsFlightRef, procFlight);
return {
changeType: "updated",
recordModified: procFlight.recordModified
};
}
return {
changeType: "aborted",
recordModified: storedRecordModified
};
});
switch (changeType) {
case "updated":
console.log("Record updated: ", JSON.stringify(procFlight));
res.status(200).json({ // 200 - OK
path: ocsFlightRef.path,
message: "Updated",
recordModified,
changeType
});
return;
case "created":
console.log("Record added: ", JSON.stringify(procFlight));
res.status(201).json({ // 201 - Created
path: ocsFlightRef.path,
message: "Created",
recordModified,
changeType
});
return;
case "aborted":
console.log("Outdated record discarded: ", JSON.stringify(procFlight));
res.status(200).json({ // 200 - OK
path: ocsFlightRef.path,
message: "Record isn't newer, nothing done.",
recordModified,
changeType
});
return;
default:
throw new Error("Unexpected value for 'changeType': " + changeType);
}
} catch (error) {
console.log("Error:", JSON.stringify(error));
res.status(500) // 500 - Internal Server Error
.json({
message: "Something went wrong",
// if available, prefer a Firebase error code
error: error.code || error.message
});
}
References
Cloud Firestore Transactions
Cloud Firestore Node SDK Reference
HTTP Event Cloud Functions

Firestore update on server only

When retrieving data from Firestore one has the option of forcing retrieval from the server. The default option is cache and server, as determined by Firestore.
I have a certain usage where a command and control node is issuing real-time commands to remote nodes backed by Firestore. This requires the updates to be done on the server (or fail) so that the C&C node has certainty on the execution (or failure) in real-time. What I would like to do is to disable use of cache with these updates. I have not found a way to do that. Is this possible with current capabilities of Firestore?
Note that it is not desirable to disable Firestore caching at a global level as the cache is beneficial in other situations.
----EDIT-----
Based on the responses I have created this update method that attempts to force updating the server using a transaction.
A couple of notes:
This is dart code.
Utils.xyz is an internal library and in this case it is being used to log.
I have reduced the network speed for the test to simulate a bad network connection.
The timeout is set to 5 seconds.
Here is the output of my log:
I/flutter (22601): [2021-06-06 22:35:30] [LogLevel.DEBUG] [FirestoreModel] [update] [We are here!]
I/flutter (22601): [2021-06-06 22:35:47] [LogLevel.DEBUG] [FirestoreModel] [update] [We are here!]
I/flutter (22601): [2021-06-06 22:36:02] [LogLevel.DEBUG] [FirestoreModel] [update] [We are here!]
I/flutter (22601): [2021-06-06 22:37:18] [LogLevel.DEBUG] [FirestoreModel] [update] [We are here!]
I/flutter (22601): [2021-06-06 22:37:20] [LogLevel.INFO] [FirestoreModel] [update] [Transaction successful in 110929ms.]
Firebase completely ignores the timeout of 5 seconds; tries to update 4 times each time ~15 seconds apart and is finally successful after 110 seconds. I am after a real-time response within seconds (5 sec) or failure.
Future<void> update(
Map<String, dynamic> data, {
WriteBatch batch,
Transaction transaction,
bool forceServer = false,
}) async {
// If updating there must be an id.
assert(this.id != null);
// Only one of batch or transaction can be non-null.
assert(batch == null || transaction == null);
// When forcing to update on server no transaction or batch is allowed.
assert(!forceServer || (batch == null && transaction == null));
try {
if (forceServer) {
DateTime start = DateTime.now();
await FirebaseFirestore.instance.runTransaction(
(transaction) async {
await update(data, transaction: transaction);
Utils.logDebug('We are here!');
},
timeout: Duration(seconds: 5),
);
Utils.logDebug('Transaction successful in ${DateTime.now().difference(start).inMilliseconds}ms.');
} else {
DocumentReference ref =
FirebaseFirestore.instance.collection(collection).doc(this.id);
if (batch != null)
batch.update(ref, data);
else if (transaction != null)
transaction.update(ref, data);
else
await ref.update(data);
}
} catch (e, s) {
Utils.logException('Error updating document $id in $collection.', e, s);
// Propagate the error.
rethrow;
}
}
This requires the updates to be done on the server (or fail)
For that you could use Transactions and batched writes.
Transactions will fail when the client is offline.
Check out doc
To get live data from the server once, you would use:
firebase.firestore()
.doc("somecollection/docId")
.get({ source: "server" })
.then((snapshot) => {
// if here, snapshot.data() is from the server
// TODO: do something with data
})
.catch((err) => {
// if here, get() encountered an error (insufficient permissions, server not available, etc)
// TODO: handle the error
});
To get realtime live data from only the server (ignoring the cache), you would use:
const unsubscribe = firebase.firestore()
.doc("somecollection/docId")
.onSnapshot({ includeMetadataChanges: true }, {
next(snapshot) {
// ignore cache data
if (snapshot.metadata.fromCache) return;
// if here, snapshot.data() is from the server
// TODO: do something with data
},
error(err) {
// if here, onSnapshot() encountered an error (insufficient permissions, etc)
// TODO: handle the error
}
});
To write to the server, you would use the normal write operations - delete(), set(), and update(); as they all return Promises that will not resolve while the client is offline. If they have resolved, the data stored on the server has been updated.
To test if you are online or not, you can try and pull a non existant document down from the server like so:
/**
* Attempts to fetch the non-existant document `/.info/connected` to determine
* if a connection to the server is available.
* #return {Promise<boolean>} promise that resolves to a boolean indicating
* whether a server connection is available
*/
function isCurrentlyOnline() {
// unlike RTDB, this data doesn't exist and has no function
// must be made readable in security rules
return firebase.firestore()
.doc(".info/connected")
.get({ source: "server" })
.then(
() => {
// read data successfully, we must be online
return true;
}, (err) => {
// failed to read data, if code is unavailable, we are offline
// for any other error, rethrow it
if (err.code === "unavailable")
return false;
throw err;
}
);
}
/**
* A function that attaches a listener to when a connection to Firestore has
* been established or when is disconnected.
*
* This function listens to the non-existant `/.info/connected` document and
* uses it's `fromCache` metadata to **estimate** whether a connection to
* Firestore is currently available.
* **Note:** This callback will only be invoked after the first successful
* connection to Firestore
*
* #param {((error: unknown | null, isOnline: boolean) => unknown)} callback the
* callback to invoke when the isOnline state changes
* #return {(() => void)} a function that unsubscribes this listener when
* invoked
*/
function onOnline(callback) {
let hasConnected = false;
// unlike RTDB, this data doesn't exist and has no function
// must be made readable in security rules
return firebase.firestore()
.doc(".info/connected")
.onSnapshot(
{ includeMetadataChanges: "server" },
{
next(snapshot) {
const { fromCache } = snapshot.metadata;
if (!hasConnected) {
if (fromCache) return; // ignore this event
hasConnected = true;
}
callback(null, !fromCache);
},
error(err) {
callback(err);
}
}
);
}

How to get public download link within a firebase storage trigger function: "onFinalize"?

I am writing a firebase cloud function that records the download link of a recentally uploaded file to real-time database:
exports.recordImage = functions.storage.object().onFinalize((object) => {
});
"object" gives me access to two variables "selfLink" and "mediaLink" but both of them when entered in a browser they return the following:
Anonymous caller does not have storage.objects.get access to ... {filename}
So, they are not public links. How can I get the public download link within this trigger function?
You have to use the asynchronous getSignedUrl() method, see the doc of the Cloud Storage Node.js library: https://cloud.google.com/nodejs/docs/reference/storage/2.0.x/File#getSignedUrl.
So the following code should do the trick:
.....
const defaultStorage = admin.storage();
.....
exports.recordImage = functions.storage.object().onFinalize(object => {
const bucket = defaultStorage.bucket();
const file = bucket.file(object.name);
const options = {
action: 'read',
expires: '03-17-2025'
};
// Get a signed URL for the file
return file
.getSignedUrl(options)
.then(results => {
const url = results[0];
console.log(`The signed url for ${filename} is ${url}.`);
return true;
})
});
Note that, in order to use the getSignedUrl() method, you need to initialize the Admin SDK with the credentials for a dedicated service account, see this SO Question & Answer firebase function get download url after successfully save image to firebase cloud storage.
*use this function:
function mediaLinkToDownloadableUrl(object) {
var firstPartUrl = object.mediaLink.split("?")[0] // 'https://storage.googleapis.com/download/storage/v1/b/abcbucket.appspot.com/o/songs%2Fsong1.mp3.mp3'
var secondPartUrl = object.mediaLink.split("?")[1] // 'generation=123445678912345&alt=media'
firstPartUrl = firstPartUrl.replace("https://storage.googleapis.com/download/storage", "https://firebasestorage.googleapis.com")
firstPartUrl = firstPartUrl.replace("v1", "v0")
firstPartUrl += "?" + secondPartUrl.split("&")[1]; // 'alt=media'
firstPartUrl += "&token=" + object.metadata.firebaseStorageDownloadTokens
return firstPartUrl
}
this is how your code might look like:
export const onAddSong = functions.storage.object().onFinalize((object) => {
console.log("object: ", object);
var url = mediaLinkToDownloadableUrl(object);
//do anything with url, like send via email or save it in your database in playlist table
//in my case I'm saving it in mongodb database
return new playlistModel({
name: storyName,
mp3Url: url,
ownerEmail: ownerEmail
})
.save() // I'm doing nothing on save complete
.catch(e => {
console.log(e) // log if error occur in database write
})
})
*I have tested this method on mp3 files, I'm sure it will work on all type of files but incase if it doesnt work for you simply go to firebase storage dashboard open any file and copy download url, and try to generate the same url in your code, and edit this answer too if possible

Sharing cookies between sites on the same domain - Headless / Decoupled CMS

The context of my challenge
I'm building a headless WordPress / WooCommerce Store.
If you're not familiar with the concept of a headless CMS, I pull the store's content (Products, and their images, text) over the WordPress / WooCommerce REST API. This way, I have the benefit of a CMS dashboard for my client whilst I get to develop in a modern language / library, in my case - React!
If possible I'd like to keep the checkout in WordPress/WooCommerce/PHP. Depending on the project I apply this code / boilerplate to I suspect that I'll have to chop and change payment gateways, and making this secure and PCI compliant will be much easier in PHP/WordPress - there's a whole host of plugins for this.
This means the entire store / front-end will live in React, with the exception of the cart in which the user will be redirected to the CMS front-end (WordPress, PHP) when they wish to complete their order.
The Challenge
This makes managing cookies for the session rather unintuitive and unorthodox. When the user is redirected from the store (React site) to the checkout (WooCommerce/PHP site) the cart session has to persist between the two sites.
Additionally, requests to WooCommerce are routed through the Node/Express server which my React client sits ons. I do this because I want to keep the WordPress address obscured, and so I can apply GraphQL to clean up my requests & responses. This issue is that in this process, the cookies are lost because my client and my CMS are communicating through a middle man (my Node server) - I require extra logic to manually manage my cookies.
The Code
When I attempt to add something to a cart, from an action creator (I'm using Redux for state management) I hit the api corresponding endpoint on my Node/Express server:
export const addToCart = (productId, quantity) => async (dispatch) => {
dispatch({type: ADD_TO_CART});
try {
// Manually append cookies somewhere here
const payload = await axios.get(`${ROOT_API}/addtocart?productId=${productId}&quantity=${quantity}`, {
withCredentials: true
});
dispatch(addToSuccess(payload));
} catch (error) {
dispatch(addToCartFailure(error));
}
};
Then on the Node/Express server I make my request to WooCommerce:
app.get('/api/addtocart', async (req, res) => {
try {
// Manually retrieve & append cookies somewhere here
const productId = parseInt(req.query.productId);
const quantity = parseInt(req.query.quantity);
const response = await axios.post(`${WP_API}/wc/v2/cart/add`, {
product_id: productId,
quantity
});
return res.json(response.data);
} catch (error) {
// Handle error
return res.json(error);
}
});
With the clues given by #TarunLalwani (thanks a million!) in his comments, I've managed to formulate a solution.
Cookie Domain Setting
Since I was working with two seperate sites, in order for this to work I had to ensure they were both on the same domain, and that the domain was set in all cookies. This ensured cookies were included in my requests between the Node / Express server (sitting on eg. somedomain.com) and the WooCommerce CMS (sitting on eg. wp.somedomain.com), rather than being exclusive to the wp.somedomain subdomain. This was achieved by setting define( 'COOKIE_DOMAIN', 'somedomain.com' ); in my wp-config.php on the CMS.
Manually Getting and Setting Cookies
My code needed significant additional logic in order for cookies to be included whilst requests were routed through my Node / Express server through the client.
In React I had to check if the cookie existed, and if it did I had to send it through in the header of my GET request to the Node / Express server.
import Cookies from 'js-cookie';
export const getSessionData = () => {
// WooCommerce session cookies are appended with a random hash.
// Here I am tracking down the key of the session cookie.
const cookies = Cookies.get();
if (cookies) {
const cookieKeys = Object.keys(cookies);
for (const key of cookieKeys) {
if (key.includes('wp_woocommerce_session_')) {
return `${key}=${Cookies.get(key)};`;
}
}
}
return false;
};
export const addToCart = (productId, quantity) => async (dispatch) => {
dispatch({type: ADD_TO_CART});
const sessionData = getSessionData();
const config = {};
if (sessionData) config['session-data'] = sessionData;
console.log('config', config);
try {
const payload = await axios.get(`${ROOT_API}/addtocart?productId=${productId}&quantity=${quantity}`, {
withCredentials: true,
headers: config
});
dispatch(addToSuccess(payload));
} catch (error) {
dispatch(addToCartFailure(error));
}
};
On the Node / Express Server I had to check if I had included a cookie (saved in req.headers with the key session-data - it was illegal to use Cookie as a key here) from the client, and if I did, append that to the header of my request going to my CMS.
If I didn't find an appended cookie, it meant this was the first request in the session, so I had to manually grab the cookie from the response I got back from the CMS and save it to the client (setCookieFunc).
app.get('/api/addtocart', async (req, res) => {
try {
const productId = parseInt(req.query.productId);
const quantity = parseInt(req.query.quantity);
const sessionData = req.headers['session-data'];
const headers = {};
if (sessionData) headers.Cookie = sessionData;
const response = await axios.post(`${WP_API}/wc/v2/cart/add`, {
product_id: productId,
quantity
}, { headers });
if (!sessionData) {
const cookies = response.headers['set-cookie'];
const setCookieFunc = (cookie) => {
const [cookieKeyValue, ...cookieOptionsArr] = cookie.split('; ');
const cookieKey = cookieKeyValue.split('=')[0];
const cookieValue = decodeURIComponent(cookieKeyValue.split('=')[1]);
const cookieOptions = { };
cookieOptionsArr.forEach(option => (cookieOptions[option.split('=')[0]] = option.split('=')[1]));
if (cookieOptions.expires) {
const expires = new Date(cookieOptions.expires);
cookieOptions.expires = expires;
}
res.cookie(cookieKey, cookieValue, cookieOptions);
};
cookies.map(cookie => setCookieFunc(cookie));
}
return res.json(response.data);
} catch (error) {
// Handle error
return res.json(error);
}
});
I'm not sure if this is the most elegant solution to the problem, but it worked for me.
Notes
I used the js-cookie library for interacting with cookies on my React client.
Gotchas
If you're trying to make this work in your development environment (using localhost) there's some extra work to be done. See Cookies on localhost with explicit domain

Resources