feat(functions): sharing function styling (#307)

This commit is contained in:
Brian Tobin
2021-04-26 14:35:59 -04:00
committed by GitHub
parent e68a49af06
commit f090b16f9e
12 changed files with 10453 additions and 288 deletions

View File

@ -21,7 +21,6 @@ module.exports = {
},
ignorePatterns: [
'/lib/**/*', // Ignore built files.
'/tests/**/*',
],
plugins: [
'@typescript-eslint',

View File

@ -0,0 +1,5 @@
{
"development": {
"app_url": "https://io-photobooth.web.app"
}
}

10122
functions/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,20 +22,19 @@
"watch": "tsc --watch"
},
"dependencies": {
"child-process-promise": "^2.2.1",
"firebase-admin": "^9.6.0",
"firebase-functions": "^3.13.2"
"firebase-functions": "^3.13.2",
"mustache": "^4.2.0"
},
"devDependencies": {
"@types/child-process-promise": "^2.2.1",
"@types/jest": "^26.0.22",
"@types/mustache": "^4.1.1",
"@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.21.0",
"eslint": "^7.23.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.1.3",
"firebase-functions-test": "^0.2.3",
"jest": "^26.6.3",
"prettier": "~2.2.1",
"prettier-eslint": "~12.0.0",

View File

@ -1,3 +1,8 @@
export const ENV = process.env.NODE_ENV;
export const UPLOAD_PATH = 'uploads';
export const SHARE_PATH = 'share';
export const ALLOWED_HOSTS = [
'localhost:5001',
'io-photobooth-dev.web.app',
'io-photo-booth.web.app',
];

View File

@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import * as functions from 'firebase-functions';
import { getShareResponse } from './';
jest.mock('firebase-admin', () => {
return {
storage: jest.fn(() => ({
@ -16,22 +20,40 @@ jest.mock('firebase-admin', () => {
});
describe('Share API', () => {
const baseReq = {
path: '',
protocol: 'http',
// @ts-ignore
get(key: string) {
return 'localhost:5001';
},
} as functions.https.Request;
test('Invalid path returns 404 and html', async () => {
const res = await getShareResponse('');
const req = Object.assign({}, baseReq);
const res = await getShareResponse(req);
expect(res.status).toEqual(404);
expect(typeof res.send).toEqual('string');
expect(res.send).toContain('<html>');
expect(res.send).toContain('DOCTYPE html');
});
test('Invalid file extension returns 404 and html', async () => {
const res = await getShareResponse('/share/image.gif');
const req = Object.assign({}, baseReq, {
path: 'wrong.gif',
});
const res = await getShareResponse(req);
expect(res.status).toEqual(404);
expect(typeof res.send).toEqual('string');
expect(res.send).toContain('<html>');
expect(res.send).toContain('DOCTYPE html');
});
test('Valid file name returns 200 and html', async () => {
const res = await getShareResponse('/share/upload.jpeg');
const req = Object.assign({}, baseReq, {
path: 'test.png',
});
const res = await getShareResponse(req);
expect(res.status).toEqual(200);
expect(typeof res.send).toEqual('string');
expect(res.send).toContain('<html>');
expect(res.send).toContain('DOCTYPE html');
});
});

View File

@ -1,23 +1,46 @@
import * as path from 'path';
import * as querystring from 'querystring';
import * as admin from 'firebase-admin';
import * as functions from 'firebase-functions';
import * as path from 'path';
import * as querystring from 'querystring';
import mustache from 'mustache';
import { UPLOAD_PATH, SHARE_PATH } from '../config';
import { UPLOAD_PATH, ALLOWED_HOSTS } from '../config';
import footerTmpl from './templates/footer';
import notFoundTmpl from './templates/notfound';
import shareTmpl from './templates/share';
import stylesTmpl from './templates/styles';
const htmlMeta = {
title: 'Google IO Photobooth',
description: 'Take a photo with special IO effects and share it with your community.',
ogUrl: 'https://io-photobooth.web.app',
ogTwitterSite: '@flutterdev',
imgPathBackground: 'public/background.jpg',
imgPathLogo: 'public/logo.svg',
ctaHeaderText: 'Take a selfie and share your photo with the community.',
ctaBtnText: 'Take Your Own',
faviconPath: 'public/favicon.png',
const VALID_IMAGE_EXT = [ '.png', '.jpeg', '.jpg' ];
const BaseHTMLContext: Record<string, string | Record<string, string>> = {
appUrl: '',
shareUrl: '',
shareImageUrl: '',
assetUrls: {
favicon: bucketPathForFile('public/favicon.png'),
bg: bucketPathForFile('public/background.jpg'),
bgMobile: bucketPathForFile('public/background-mobile.jpg'),
notFoundPhoto: bucketPathForFile('public/404-photo.png'),
fixedPhotosLeft: bucketPathForFile('public/table-photos-left.png'),
fixedPhotosRight: bucketPathForFile('public/table-photos-right.png'),
},
meta: {
title: 'Google I/O Photo Booth',
desc: (
'Take a photo at the Google I/O Photo Booth! ' +
'Built for Google I/O 2021 with Flutter & Firebase.'
),
message: (
'Check out my photo taken at the #IOPhotoBooth. ' +
'Join the fun at #GoogleIO and take your own!'
),
},
footer: mustache.render(footerTmpl, {}),
styles: '',
};
/**
* Returns bucket path
* @param {string} filename
@ -25,186 +48,88 @@ const htmlMeta = {
*/
function bucketPathForFile(filename: string): string {
return (
`https://firebasestorage.googleapis.com/v0/b/${admin.storage().bucket().name}` +
'https://firebasestorage.googleapis.com/v0' +
`/b/${admin.storage().bucket().name}` +
`/o/${querystring.escape(filename)}?alt=media`
);
}
/**
* Return not found response
* @return {Object} Corrent not found response?
* Return a local file HTML template built with context
* @param {string} tmpl - html template string
* @param {Object} context - html context dict
* @return {string} HTML template string
*/
function getNotFoundHTML(): string {
return (`
<!doctype html>
<html>
<head>
<title>${htmlMeta.title}</title>
<meta name='viewport' content='width=device-width,initial-scale=1' />
<style>
html {
font-family: 'Google Sans', 'Roboto', sans-serif;
background-repeat: no-repeat;
background-image: url('${bucketPathForFile(htmlMeta.imgPathBackground)}');
background-position: center center;
background-attachement: fixed;
background-size: cover;
}
.logo {
width: 90%;
margin: 3rem auto;
display:block;
max-width: 800px;
}
.share-content {
background: #fff;
width: 95%;
max-width: 800px;
border-radius: 6px;
padding: 2rem 1rem 3rem;
margin: 0 auto;
text-align: center;
}
</style>
</head>
<body>
<img class='logo' src='${bucketPathForFile(htmlMeta.imgPathLogo)}'/>
<div class='share-content'>
<h2>Some clever not found message :)</h2>
</div>
</body>
</html>
`);
function renderTemplate(
tmpl: string, context: Record<string, string | Record<string, string>>
): string {
context.styles = mustache.render(stylesTmpl, context);
return mustache.render(tmpl, context);
}
/**
* Render the 404 html page
* @param {string} imageFileName - filename of storage image
* @param {string} baseUrl - http base fully qualified URL
* @return {string} HTML string
*/
function renderNotFoundPage(imageFileName: string, baseUrl: string): string {
const context = Object.assign({}, BaseHTMLContext, {
appUrl: baseUrl,
shareUrl: `${baseUrl}/share/${imageFileName}`,
shareImageUrl: bucketPathForFile(`${UPLOAD_PATH}/${imageFileName}`),
});
return renderTemplate(notFoundTmpl, context);
}
/**
* Populate and return the share page HTML for given path
* @param {string} filePath
* @param {string} imageFileName - filename of storage image
* @param {string} baseUrl - http base fully qualified URL
* @return {string} HTML string
*/
function generateShareHTML(filePath: string): string {
/*eslint-disable */
return (`
<!doctype html>
<html>
<head>
<title>${htmlMeta.title}</title>
<meta name='viewport' content='width=device-width,initial-scale=1' />
<meta name='descripton' content='${htmlMeta.description}'>
<meta property='og:description' content='${htmlMeta.description}'>
<meta property='og:title' content='${htmlMeta.title}'>
<meta property='og:url' content='${htmlMeta.ogUrl}'>
<meta property='og:image' content='${bucketPathForFile(filePath)}'>
<meta name='twitter:text:title' content='${htmlMeta.title}'>
<meta name='twitter:card' content='summary_large_image'>
<meta name='twitter:site' content='${htmlMeta.ogTwitterSite}'>
<meta name='twitter:title' content='${htmlMeta.title}'>
<meta name='twitter:description' content='${htmlMeta.description}'>
<meta name='twitter:image' content='${bucketPathForFile(filePath)}'>
<link rel='icon' type='image/png' href='${bucketPathForFile(htmlMeta.faviconPath)}'>
<style>
html {
font-family: 'Google Sans', 'Roboto', sans-serif;
background-repeat: no-repeat;
background-image: url('${bucketPathForFile(htmlMeta.imgPathBackground)}');
background-position: center center;
background-attachement: fixed;
background-size: cover;
}
.logo {
width: 90%;
margin: 3rem auto;
display:block;
max-width: 800px;
}
.share-content {
background: #fff;
width: 95%;
max-width: 800px;
border-radius: 6px;
padding: 2rem 1rem 3rem;
margin: 0 auto;
text-align: center;
}
.share-image {
width: 80%;
margin: 0 1rem;
}
.share-header {
margin: 4rem auto 0;
font-size:1.5rem;
}
.share-btn {
background-color: #1389FD;
border-color: #1389FD;
border-radius: 0;
border: 1px solid transparent;
box-sizing: border-box;
color: #fff;
cursor: pointer;
display: inline-block;
font-size: 2rem;
font-weight: 400;
line-height: 1.5;
margin-top: 3rem;
min-width: 250px;
padding: 1rem 1.5rem;
text-align: center;
text-decoration: none;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
user-select: none;
vertical-align: middle;
white-space: normal;
}
</style>
</head>
<body>
<img class='logo' src='${bucketPathForFile(htmlMeta.imgPathLogo)}'/>
<div class='share-content'>
<img class='share-image' src='${bucketPathForFile(filePath)}'/>
<div class='share-header'> ${htmlMeta.ctaHeaderText} </div>
<a class='share-btn' href='${htmlMeta.ogUrl}'> ${htmlMeta.ctaBtnText} </a>
</div>
</body>
</html>
`);
/* eslint-enable */
function renderSharePage(imageFileName: string, baseUrl: string): string {
const context = Object.assign({}, BaseHTMLContext, {
appUrl: baseUrl,
shareUrl: `${baseUrl}/share/${imageFileName}`,
shareImageUrl: bucketPathForFile(`${UPLOAD_PATH}/${imageFileName}`),
});
return renderTemplate(shareTmpl, context);
}
/**
* Get share response
* @param {string} reqPath
* @param {Object} req - request object
* @return {Object} share response
*/
export async function getShareResponse(
reqPath: string
req: functions.https.Request
): Promise<{ status: number; send: string }> {
try {
const { dir, ext, base } = path.parse(reqPath);
const isValidPath = (
dir === `/${SHARE_PATH}` && [ '.png', '.jpeg', '.jpg' ].includes(ext)
);
const host = req.get('host') ?? '';
const baseUrl = `${req.protocol}://${host}`;
const { ext, base: imageFileName } = path.parse(req.path);
const storagePath = `${UPLOAD_PATH}/${base}`;
let exists: [boolean] | undefined;
if (isValidPath) {
exists = await admin.storage().bucket().file(storagePath).exists();
if (exists && exists[0]) {
return {
status: 200,
send: generateShareHTML(storagePath),
};
}
if (!ALLOWED_HOSTS.includes(host) || !VALID_IMAGE_EXT.includes(ext)) {
return {
status: 404,
send: renderNotFoundPage(imageFileName, baseUrl),
};
}
functions.logger.info('File path invalid or not found', {
storagePath, exists, valid: isValidPath,
});
const imageBlobPath = `${UPLOAD_PATH}/${imageFileName}`;
const imageExists = await admin.storage().bucket().file(imageBlobPath).exists();
if (Array.isArray(imageExists) && imageExists[0]) {
return {
status: 200,
send: renderSharePage(imageFileName, baseUrl),
};
}
return {
status: 404,
send: getNotFoundHTML(),
send: renderNotFoundPage(imageFileName, baseUrl),
};
} catch (error) {
functions.logger.error(error);
@ -215,14 +140,12 @@ export async function getShareResponse(
}
}
/**
* Public sharing function
*/
export const shareImage = functions.https.onRequest(async (req, res) => {
const { status, send } = await getShareResponse(req.path);
// Development only, set to actual production hosting domain
const { status, send } = await getShareResponse(req);
res.set('Access-Control-Allow-Origin', '*');
res.status(status).send(send);
});

View File

@ -0,0 +1,19 @@
export default `
<footer>
<div class="left">
<span>Made with
<a href="https://flutter.dev">Futter</a> &amp;
<a href="https://firebase.google.com">Firebase</a>
</ul>
</div>
<div class="right">
<ul>
<li><a href="https://events.google.com/io/adventure/?lng=en">Google I/O</a></li>
<li><a href="https://flutter.dev/docs/codelabs">Codelab</a></li>
<li><a href="">How It's Made</a></li>
<li><a href="https://policies.google.com/terms">Terms of Service</a></li>
<li><a href="https://policies.google.com/privacy">Privacy Policy</a></li>
</ul>
</div>
</footer>
`;

View File

@ -0,0 +1,45 @@
export default `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/png" href="{{{assetUrls.favicon}}}">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{meta.title}}</title>
<meta name="descripton" content="{{meta.desc}}">
<meta property="og:title" content="{{meta.message}}">
<meta property="og:description" content="{{meta.desc}}">
<meta property="og:url" content="{{{shareUrl}}}">
<meta property="og:image" content="{{{shareImageUrl}}}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{meta.title}}">
<meta name="twitter:text:title" content="{{meta.message}}">
<meta name="twitter:description" content="{{metaDesc}}">
<meta name="twitter:image" content="{{{shareImageUrl}}}">
<meta name="twitter:site" content="@flutterdev">
<link href="https://fonts.googleapis.com/css?family=Google+Sans:400,500" rel="stylesheet">
<style>{{{styles}}}</style>
</head>
<body>
<img src="{{{assetUrls.fixedPhotosLeft}}}" class="fixed-photos left">
<img src="{{{assetUrls.fixedPhotosRight}}}" class="fixed-photos right">
<main>
<div class="share-image no-shadow">
<img src="{{{assetUrls.notFoundPhoto}}}">
</div>
<div class="text">
<h1>Flutter taken with Flutter</h1>
<h2>Join the fun! Grab a photo with your favorite Google mascot
at the I/O Photo Booth.</h2>
<a class="share-btn" href="{{appUrl}}">Take your own</a>
</div>
</main>
{{{footer}}}
</body>
</html>
`;

View File

@ -0,0 +1,45 @@
export default `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/png" href="{{{assetUrls.favicon}}}">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{meta.title}}</title>
<meta name="descripton" content="{{meta.desc}}">
<meta property="og:title" content="{{meta.message}}">
<meta property="og:description" content="{{meta.desc}}">
<meta property="og:url" content="{{{shareUrl}}}">
<meta property="og:image" content="{{{shareImageUrl}}}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{meta.title}}">
<meta name="twitter:text:title" content="{{meta.message}}">
<meta name="twitter:description" content="{{metaDesc}}">
<meta name="twitter:image" content="{{{shareImageUrl}}}">
<meta name="twitter:site" content="@flutterdev">
<link href="https://fonts.googleapis.com/css?family=Google+Sans:400,500" rel="stylesheet">
<style>{{{styles}}}</style>
</head>
<body>
<img src="{{{assetUrls.fixedPhotosLeft}}}" class="fixed-photos left">
<img src="{{{assetUrls.fixedPhotosRight}}}" class="fixed-photos right">
<main>
<div class="share-image">
<img src="{{{shareImageUrl}}}">
</div>
<div class="text">
<h1>Photo taken with Flutter</h1>
<h2>Join the fun! Grab a photo with your favorite Google mascot
at the I/O Photo Booth.</h2>
<a class="share-btn" href="{{{appUrl}}}">Get started</a>
</div>
</main>
{{{footer}}}
</body>
</html>
`;

View File

@ -0,0 +1,195 @@
export default `
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
font-family: "Google Sans", sans-serif;
font-size: 12px;
background-image: url("{{{assetUrls.bgMobile}}}");
background-repeat: no-repeat;
background-position: center;
background-size: cover;
display: flex;
flex-direction: column;
}
*, ::before, ::after {
box-sizing: border-box;
}
.fixed-photos {
display: none;
}
main {
width: 95%;
max-width: 1000px;
margin: 0 auto 45px;
text-align: center;
flex: 1 0 auto;
z-index: 10;
position: relative;
}
.share-image {
margin: 2rem auto;
width: 90%;
transform: rotate(-5deg);
}
.share-image img {
width: 100%;
box-shadow: -3px 9px 7px 1px rgba(0, 0, 0, 0.4);
}
.share-image.no-shadow img {
box-shadow: none;
}
h1, h2 {
color: white;
margin-block-start: 0;
margin-block-end: 0;
}
h1 {
line-height: 1.2;
font-size: 32px;
font-weight: 700;
width: 67%;
margin: 0 auto 25px;
}
h2 {
line-height: 1.3;
font-size: 18px;
font-weight: 100;
width: 75%;
margin: 0 auto 35px;
}
.share-btn {
display: inline-block;
font-weight: 400;
line-height: 1.5;
text-align: center;
text-decoration: none;
line-height: 1;
padding: 16px 0;
background-color: #428eff;
color: white;
font-size: 18px;
border-radius: 50px;
width: 208px;
}
footer {
width: 100%;
color: white;
font-size: 14px;
font-weight: 100;
flex-direction: column;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 5% 1.5rem;
text-align: center;
position: relative;
flex-shrink: 0;
z-index: 10;
position: relative;
}
footer a {
color: white;
text-decoration: none;
white-space: nowrap;
}
footer a:hover {
text-decoration: underline;
}
footer ul {
margin: 0;
padding: 0;
line-height: 2;
}
footer .left {
font-size: 16px;
font-weight: 400;
margin-bottom: 17px;
}
footer .left a {
text-decoration: underline;
}
footer li {
display: inline;
margin-left: 1rem;
}
@media (min-width:768px) {
body {
background-image: url("{{{assetUrls.bg}}}");
}
.fixed-photos {
position: fixed;
top: 0;
background-repeat: no-repeat;
background-size: contain;
z-index: -1;
display: block;
width: 780px;
}
.fixed-photos.left {
left: -22%;
}
.fixed-photos.right {
right: -22%;
}
.share-image {
margin: 4.25rem auto 3rem;
width: 710px;
position: relative;
left: -22px;
}
h1, h2 {
width: 100%;
}
h1 {
font-size: 56px;
}
h2 {
font-size: 24px;
}
.share-btn {
font-size: 22px;
}
footer {
flex-direction: row;
text-align: left;
padding-left: 2%;
padding-right: 2%;
}
footer .left {
font-size: 18px;
margin-bottom: 0;
}
}
`;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB