Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions ci/dynamo-removal/report-proof.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ const rid=process.env.DYNAMO_PROOF_REPORT_ID;
assert(/^rlocal[a-z0-9]+$/.test(rid),'generated local report ID required');
const output=process.env.DYNAMO_PROOF_OUTPUT;
assert(output,'DYNAMO_PROOF_OUTPUT required');fs.mkdirSync(output,{recursive:true});
// The report must render with every non-local host blocked. Deliberate third
// parties are listed by name; they are blocked too, only the assertion allows them.
const deliberateHosts=new Set(['scripts.simpleanalyticscdn.com','queue.simpleanalyticscdn.com']);
const localUrl=/^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//;
(async()=>{
const browser=await chromium.launch({headless:true});const receipts=[];
try {
for(const route of ['report','topicStats','topicReport']){
const page=await browser.newPage({viewport:{width:1440,height:1100}});
const errors=[];const responses=[];let narrativeEvidence;
const errors=[];const responses=[];const blocked=[];let narrativeEvidence;
page.on('pageerror',error=>errors.push(error.message));
await page.route(url=>!localUrl.test(url.href),route=>{blocked.push(route.request().url());return route.abort('blockedbyclient')});
page.on('response',async response=>{
if(!response.url().includes('/api/'))return;
let body;try{body=await response.text()}catch{body='unavailable'}
Expand Down Expand Up @@ -61,8 +66,10 @@ assert(output,'DYNAMO_PROOF_OUTPUT required');fs.mkdirSync(output,{recursive:tru
const body=await page.locator('body').innerText();
await page.screenshot({path:path.join(output,route+'.png'),fullPage:true});
await page.screenshot({path:path.join(output,route+'-viewport.png')});
const record={route,body,errors,responses,narrativeEvidence};receipts.push(record);
const record={route,body,errors,blocked,responses,narrativeEvidence};receipts.push(record);
fs.writeFileSync(path.join(output,'browser.json'),JSON.stringify(receipts,null,2));
const thirdParty=blocked.filter(url=>!deliberateHosts.has(new URL(url).hostname));
assert.deepEqual(thirdParty,[],route+': requests to third-party hosts');
assert.equal(errors.length,0,route+': '+errors.join('; '));
assert(responses.length>0,route+': API requests observed');
assert(responses.every(r=>r.status<400),route+': '+responses.filter(r=>r.status>=400).map(r=>r.url));
Expand Down
10 changes: 10 additions & 0 deletions client-admin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client-admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"webpack-dev-server": "~5.2.2"
},
"dependencies": {
"@fontsource/space-mono": "5.3.0",
"@reduxjs/toolkit": "^2.11.0",
"@theme-ui/mdx": "^0.17.2",
"oidc-client-ts": "^3.4.1",
Expand Down
7 changes: 4 additions & 3 deletions client-admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@

<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link
href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap"
rel="stylesheet" />
<!-- Space Mono, copied from a pinned npm package into this build (see webpack.config.js) -->
<% htmlWebpackPlugin.options.fontStylesheets.forEach(function (href) { %>
<link href="<%= href %>" rel="stylesheet" />
<% }) %>
<!-- 100% privacy-first analytics -->
<% if (htmlWebpackPlugin.options.isProduction) { %>
<script
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/1f30d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/1f30e.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/1f3ce.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/1f47e.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/1f4be.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/1f4d6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/1f529.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/2697.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/26a1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client-admin/public/twemoji/14.0.2/72x72/2764.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Heading, Box, Text } from 'theme-ui'
import { useCallback, useRef } from 'react'
import { useDispatch } from 'react-redux'
import { useAuth } from 'react-oidc-context'
import emoji from 'react-easy-emoji'
import emoji from '../../util/emoji'

import { CheckboxField } from './CheckboxField'
import { useConversationData } from '../../util/conversation_data'
Expand Down
2 changes: 1 addition & 1 deletion client-admin/src/components/landers/KnowledgeBase.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import PropTypes from 'prop-types'
import { Box, Link } from 'theme-ui'
import emoji from 'react-easy-emoji'
import emoji from '../../util/emoji'

const KnowledgeBase = ({ e, url, txt }) => {
return (
Expand Down
2 changes: 1 addition & 1 deletion client-admin/src/components/landers/lander-footer.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Component } from 'react'
import { Box, Link, Heading } from 'theme-ui'

import emoji from 'react-easy-emoji'
import emoji from '../../util/emoji'

class Header extends Component {
render() {
Expand Down
57 changes: 57 additions & 0 deletions client-admin/src/selfContained.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// The admin pages load no code, styles, fonts or images from a third-party
// host: the theme font and the emoji images are served from this client's
// own build.
import fs from 'fs'
import path from 'path'
import { render } from '@testing-library/react'
import emoji, { TWEMOJI_BASE_URL } from './util/emoji'

const root = path.join(__dirname, '..')
const publicDir = path.join(root, 'public')

// Deliberate third parties, kept until the project rules on them.
const DELIBERATE_HOSTS = ['scripts.simpleanalyticscdn.com', 'queue.simpleanalyticscdn.com']

function sourceFiles(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(dir, entry.name)
if (entry.isDirectory()) return sourceFiles(file)
return /\.js$/.test(entry.name) && !/\.test\.js$/.test(entry.name) ? [file] : []
})
}

function emojiSrcs(text) {
const { container } = render(<span>{emoji(text)}</span>)
return Array.from(container.querySelectorAll('img'), (img) => img.getAttribute('src'))
}

describe('self-contained admin client', () => {
it('index.html loads nothing from a third-party host except the deliberate ones', () => {
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8')
const urls = Array.from(html.matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/g), (m) => m[1])
const hosts = urls
.filter((u) => /^(https?:)?\/\//.test(u))
.map((u) => new URL(u, 'https://example.invalid').hostname)
expect(urls).toContain('/favicon.ico')
expect(hosts.filter((h) => !DELIBERATE_HOSTS.includes(h))).toEqual([])
expect(html).toContain('htmlWebpackPlugin.options.fontStylesheets')
})

it('draws emoji from the client assets', () => {
expect(emojiSrcs('💾')).toEqual([`${TWEMOJI_BASE_URL}72x72/1f4be.png`])
})

it('ships an image for every emoji the app renders', () => {
const used = new Set()
for (const file of sourceFiles(path.join(root, 'src'))) {
const text = fs.readFileSync(file, 'utf8')
Array.from(text.matchAll(/\bemoji\('([^']+)'\)/g), (m) => used.add(m[1]))
Array.from(text.matchAll(/<KnowledgeBase[^>]*\be="([^"]+)"/g), (m) => used.add(m[1]))
}
expect(used.size).toBeGreaterThan(10)
const missing = Array.from(used)
.flatMap((text) => emojiSrcs(text))
.filter((src) => !fs.existsSync(path.join(publicDir, src)))
expect(missing).toEqual([])
})
})
17 changes: 17 additions & 0 deletions client-admin/src/util/emoji.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import reactEasyEmoji from 'react-easy-emoji'

// react-easy-emoji draws each emoji as a Twemoji 14.0.2 image. By default it
// loads that image from a CDN; these options load the same files from this
// client's own assets (public/twemoji/14.0.2/72x72), which hold exactly the
// images the app uses. A new emoji needs its PNG copied there.
//
// Twemoji graphics: Copyright 2020 Twitter, Inc and other contributors,
// licensed under CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/),
// from https://github.com/twitter/twemoji.
export const TWEMOJI_BASE_URL = '/twemoji/14.0.2/'

const options = { baseUrl: TWEMOJI_BASE_URL, protocol: '', size: '72x72', ext: '.png' }

export default function emoji(input) {
return reactEasyEmoji(input, options)
}
48 changes: 47 additions & 1 deletion client-admin/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,27 @@ import TerserPlugin from 'terser-webpack-plugin'
import EventHooksPlugin from 'event-hooks-webpack-plugin'
import * as glob from 'glob'
import fs from 'fs'
import { createRequire } from 'module'
import { fileURLToPath } from 'url'
import webpack from 'webpack'

// Get __dirname equivalent in ESM
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cjsRequire = createRequire(import.meta.url)

// Development port
const port = process.env.PORT || 3002

// The theme's font (Space Mono 400, 700 and 400 italic), served from this
// build rather than a font CDN: the CSS and font files are copied unchanged
// from the pinned @fontsource/space-mono package. The directory carries the
// package version because these files are served with a one-year cache.
const spaceMonoPackage = path.dirname(cjsRequire.resolve('@fontsource/space-mono/package.json'))
const spaceMonoDir = `static/fonts/space-mono-${cjsRequire('@fontsource/space-mono/package.json').version}`
const spaceMonoStyles = ['400', '700', '400-italic']
const longCache = 'no-transform,public,max-age=31536000,s-maxage=31536000'

export default (env, argv) => {
const isProduction = argv.mode === 'production'
const isDevelopment = !isProduction
Expand Down Expand Up @@ -96,7 +107,24 @@ export default (env, argv) => {
template: 'public/index.html',
filename: isProduction ? 'index_admin.html' : 'index.html',
inject: 'body',
isProduction: isProduction // Pass isProduction to the template
isProduction: isProduction, // Pass isProduction to the template
fontStylesheets: spaceMonoStyles.map((style) => `/${spaceMonoDir}/${style}.css`)
}),
new CopyPlugin({
patterns: [
{
context: spaceMonoPackage,
from: `{${spaceMonoStyles.join(',')}}.css`,
to: `${spaceMonoDir}/`,
toType: 'dir'
},
{
context: spaceMonoPackage,
from: `files/space-mono-*-{400-normal,700-normal,400-italic}.{woff,woff2}`,
to: `${spaceMonoDir}/`,
toType: 'dir'
}
]
}),
new webpack.DefinePlugin({
'process.env.ADMIN_UIDS': JSON.stringify(process.env.ADMIN_UIDS),
Expand Down Expand Up @@ -158,6 +186,24 @@ export default (env, argv) => {
'Cache-Control': 'no-transform,public,max-age=31536000,s-maxage=31536000'
})

// Headers for the theme font and the emoji images (versioned paths)
writeHeadersJson(`${spaceMonoDir}/*.css`, {
'Content-Type': 'text/css',
'Cache-Control': longCache
})
writeHeadersJson(`${spaceMonoDir}/files/*.woff2`, {
'Content-Type': 'font/woff2',
'Cache-Control': longCache
})
writeHeadersJson(`${spaceMonoDir}/files/*.woff`, {
'Content-Type': 'font/woff',
'Cache-Control': longCache
})
writeHeadersJson('twemoji/*/72x72/*.png', {
'Content-Type': 'image/png',
'Cache-Control': longCache
})

// Headers for other files
writeHeadersJson('favicon.ico')
}
Expand Down
7 changes: 7 additions & 0 deletions client-participation/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client-participation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"handlebones": "github:pol-is/handlebones#master",
"hull.js": "~0.2.11",
"jquery": "~1.12.4",
"jwt-decode": "3.1.2",
"lodash": "~4.17.23",
"markdown": "~0.5.0",
"process": "~0.11.10",
Expand Down
2 changes: 1 addition & 1 deletion client-participation/public/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
window.preload = "REPLACE_THIS_WITH_PRELOAD_DATA";
window.version = "<%= versionString %>";
</script>
<script src="https://cdn.jsdelivr.net/npm/jwt-decode@latest/build/jwt-decode.min.js"></script>
<script src="<%= jwtDecodeSrc %>"></script>

<style>
.displaynone {
Expand Down
33 changes: 33 additions & 0 deletions client-participation/test/selfContained.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// The participation page loads no code from a third-party host except the
// deliberate analytics: window.jwt_decode comes from this build.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");

const root = path.join(__dirname, "..");
const template = fs.readFileSync(path.join(root, "public/index.ejs"), "utf8");

// Deliberate third parties, kept until the project rules on them. Google
// Analytics is only present when the build sets GA_TRACKING_ID.
const DELIBERATE_HOSTS = ["www.googletagmanager.com", "scripts.simpleanalyticscdn.com", "queue.simpleanalyticscdn.com"];

test("index.ejs loads nothing from a third-party host except the deliberate ones", () => {
const urls = [...template.matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/g)].map((m) => m[1]);
const hosts = urls
.filter((u) => /^(https?:)?\/\//.test(u))
.map((u) => new URL(u, "https://example.invalid").hostname);
assert.ok(hosts.includes("scripts.simpleanalyticscdn.com"), "the scan sees the page's script tags");
assert.deepEqual(
hosts.filter((h) => !DELIBERATE_HOSTS.includes(h)),
[]
);
});

test("jwt_decode is the pinned 3.1.2 browser build, copied into this build", () => {
assert.match(template, /<script src="<%= jwtDecodeSrc %>"><\/script>/);
const pkg = require(path.join(root, "node_modules/jwt-decode/package.json"));
assert.equal(pkg.version, "3.1.2");
const browserBuild = fs.readFileSync(path.join(root, "node_modules/jwt-decode/build/jwt-decode.js"), "utf8");
assert.match(browserBuild, /window\.jwt_decode = jwtDecode/);
});
11 changes: 11 additions & 0 deletions client-participation/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");

const outputDirectory = "dist";

// index.ejs reads window.jwt_decode before the main bundle loads. The browser
// build is copied unchanged from the pinned npm package (the version the page
// has been loading) and served from this build, never from a CDN.
const jwtDecodeScript = `js/jwt-decode-${require("jwt-decode/package.json").version}.js`;

/**
* Generates .headersJson files alongside files served by the file-server. Reading these files instructs file-server
* what HTML headers should be added to each file.
Expand Down Expand Up @@ -219,6 +224,11 @@ module.exports = (env, options) => {
{
from: "node_modules/font-awesome/fonts/**/*",
to: "./fonts/[name][ext]"
},
{
from: "node_modules/jwt-decode/build/jwt-decode.js",
to: jwtDecodeScript,
info: { minimized: true }
}
]
}),
Expand All @@ -228,6 +238,7 @@ module.exports = (env, options) => {
filename: "index.html",
templateParameters: {
versionString: pkg.version,
jwtDecodeSrc: `/${jwtDecodeScript}`,
gaTrackingId: gaTrackingId,
oidcCacheKeyPrefix: oidcCacheKeyPrefix,
oidcCacheKeyIdTokenSuffix: oidcCacheKeyIdTokenSuffix,
Expand Down
Loading
Loading