New features:
- `t.co` urls resolution map as attachment (CSV file)
- PDF filenames based on twitter url and creation date

Dev "quality of life" updates:
- Basic integration tests and GitHub CI
- HTML 404s
- PDF "producer" field now contains the app's version number
- Better checkbox field name
- Documentation edits
pull/13/head
Matteo Cargnelutti 2022-11-24 02:20:30 -05:00
rodzic c6443035cc
commit de8d6f1038
24 zmienionych plików z 7603 dodań i 187 usunięć

Wyświetl plik

@ -1,4 +1,4 @@
name: Tests
name: Deploy to prod
on: [push]

33
.github/workflows/tests.yml vendored 100644
Wyświetl plik

@ -0,0 +1,33 @@
name: Test suite
on:
pull_request:
workflow_dispatch:
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '18.x'
cache: 'npm'
- uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: 'pip'
- name: Install system dependencies
run: sudo apt-get install -y bash gcc g++ python3-dev zlib1g zlib1g-dev libjpeg-dev libssl-dev libffi-dev ghostscript poppler-utils
- name: Install app dependencies
run: npm install
- name: Install Playwright Browser
run: npx playwright install --with-deps chrome
- name: Run tests
run: npm run test

15
.gitignore vendored
Wyświetl plik

@ -1,11 +1,16 @@
node_modules
*.p12
*.pem
test.js
.DS_Store
app/data/*.json
app/data/*.tsv
.nyc_output
yt-dlp
*.p12
*.pem
app/tmp/*.pdf
app/tmp/*.mp4
public-keys.txt
app/data/*.json
app/data/*.tsv
fixtures/data/*.tsv

Wyświetl plik

@ -1,10 +1,13 @@
# "Save Your Threads" - thread-keeper 📚
# thread-keeper 📚
High-fidelity capture of Twitter threads as sealed PDFs: [social.perma.cc](https://social.perma.cc).
High-fidelity capture of Twitter threads as sealed PDFs @ [social.perma.cc](https://social.perma.cc).
[![](github.png)](https://social.perma.cc)
An experiment of the [Harvard Library Innovation Lab](https://lil.law.harvard.edu).
> 🚧 Experimental / Prototype. Early release to be consolidated.
> 🚧 Experimental - Prototype.
> Early release to be consolidated.
---
@ -127,5 +130,14 @@ npm run docgen
Generates JSDoc-based code documentation under `/docs`.
### test
```bash
npm run test
```
Runs the test suite. Requires test fixtures _(see `fixtures` folder)_.
> ⚠️ At the moment, this codebase only features a very limited set of high-level integration tests.
[☝️ Back to summary](#summary)

Wyświetl plik

@ -1,9 +1,10 @@
/**
* thread-keeper
* @module const.js
* @module const
* @author The Harvard Library Innovation Lab
* @license MIT
*/
import fs from "fs";
/**
* Path to the folder holding the certificates used for signing the PDFs.
@ -51,4 +52,15 @@ export const MAX_PARALLEL_CAPTURES_TOTAL = 200;
* Maximum capture processes that can be run in parallel for a given key.
* @constant
*/
export const MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY = 20;
export const MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY = 20;
/**
* APP version. Pulled from `package.json` by default.
* @constant
*/
export const APP_VERSION = (() => {
const appPackage = JSON.parse(fs.readFileSync("./package.json"));
return appPackage?.version ? appPackage.version : "0.0.0";
})();
console.log(APP_VERSION);

Wyświetl plik

@ -1,6 +1,6 @@
/**
* thread-keeper
* @module server.js
* @module server
* @author The Harvard Library Innovation Lab
* @license MIT
*/
@ -16,10 +16,11 @@ import {
MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY,
} from "./const.js";
/**
* @type {SuccessLog}
*/
const successLog = new SuccessLog();
export const successLog = new SuccessLog();
/**
* @type {AccessKeys}
@ -39,17 +40,41 @@ const accessKeys = new AccessKeys();
* maxPerAccessKey: number
* }}
*/
const CAPTURES_WATCH = {
export const CAPTURES_WATCH = {
currentTotal: 0,
maxTotal: MAX_PARALLEL_CAPTURES_TOTAL,
currentByAccessKey: {},
maxPerAccessKey: MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY,
}
export default async function (fastify, opts) {
// Adds support for `application/x-www-form-urlencoded`
fastify.register(import('@fastify/formbody'));
fastify.register(import("@fastify/static"), {
root: STATIC_PATH,
prefix: "/static/",
});
fastify.setNotFoundHandler((request, reply) => {
reply
.code(404)
.type('text/html')
.send(nunjucks.render(`${TEMPLATES_PATH}404.njk`));
});
fastify.get('/', index);
fastify.post('/', capture);
fastify.get('/check', check);
fastify.get('/api/v1/hashes/check/:hash', checkHash);
};
/**
* [GET] /
* Shows the landing page / form.
* [GET] /
* Shows the landing page and capture form.
* Assumes `fastify` is in scope.
*
* @param {fastify.FastifyRequest} request
@ -65,32 +90,18 @@ async function index(request, reply) {
.send(html);
}
/**
* [GET] /check
* Shows the "check" page /check form. Loads certificates history files in the process.
* Assumes `fastify` is in scope.
*
* @param {fastify.FastifyRequest} request
* @param {fastify.FastifyReply} reply
* @returns {Promise<fastify.FastifyReply>}
*/
async function check(request, reply) {
const html = nunjucks.render(`${TEMPLATES_PATH}check.njk`, {
signingCertsHistory: CertsHistory.load("signing"),
timestampsCertsHistory: CertsHistory.load("timestamping")
});
return reply
.code(200)
.header('Content-Type', 'text/html; charset=utf-8')
.send(html);
}
/**
* [POST] `/`
* Processes a request to capture a `twitter.com` url.
* Serves PDF bytes directly if operation is successful.
* Returns to form with specific error code, passed as `errorReason`, otherwise.
* Subject to captures rate limiting (see `CAPTURES_WATCH`).
*
* Body is expected as `application/x-www-form-urlencoded` with the following fields:
* - access-key
* - url
* - unfold-thread (optional)
*
* Assumes `fastify` is in scope.
*
* @param {fastify.FastifyRequest} request
@ -171,6 +182,7 @@ async function capture(request, reply) {
// Process capture request
//
try {
// Add request to total and per-key counter
CAPTURES_WATCH.currentTotal += 1;
if (accessKey in CAPTURES_WATCH.currentByAccessKey) {
@ -180,15 +192,27 @@ async function capture(request, reply) {
CAPTURES_WATCH.currentByAccessKey[accessKey] = 1;
}
const tweets = new TwitterCapture(data.url, {runBrowserBehaviors: "auto-scroll" in data});
const tweets = new TwitterCapture(data.url, {runBrowserBehaviors: "unfold-thread" in data});
const pdf = await tweets.capture();
successLog.add(accessKey, pdf);
// Generate a filename for the PDF based on url.
// Example: harvardlil-status-123456789-2022-11-25.pdf
const filename = (() => {
const url = new URL(tweets.url);
let filename = "twitter.com";
filename += `${url.pathname}-`;
filename += `${(new Date()).toISOString().substring(0, 10)}`; // YYYY-MM-DD
filename = filename.replace(/[^a-z0-9]/gi, "-").toLowerCase();
return `${filename}.pdf`;
})();
return reply
.code(200)
.header('Content-Type', 'application/pdf')
.header('Content-Disposition', 'attachment; filename="capture.pdf"')
.header('Content-Disposition', `attachment; filename="${filename}"`)
.send(pdf);
}
catch(err) {
@ -212,7 +236,27 @@ async function capture(request, reply) {
CAPTURES_WATCH.currentByAccessKey[data["access-key"]] -= 1;
}
}
}
/**
* [GET] /check
* Shows the "check" page /check form. Loads certificates history files in the process.
* Assumes `fastify` is in scope.
*
* @param {fastify.FastifyRequest} request
* @param {fastify.FastifyReply} reply
* @returns {Promise<fastify.FastifyReply>}
*/
async function check(request, reply) {
const html = nunjucks.render(`${TEMPLATES_PATH}check.njk`, {
signingCertsHistory: CertsHistory.load("signing"),
timestampsCertsHistory: CertsHistory.load("timestamping")
});
return reply
.code(200)
.header('Content-Type', 'text/html; charset=utf-8')
.send(html);
}
/**
@ -238,25 +282,3 @@ async function checkHash(request, reply) {
return reply.code(found ? 200 : 404).send();
}
export default async function (fastify, opts) {
// Adds support for `application/x-www-form-urlencoded`
fastify.register(import('@fastify/formbody'));
// Serves files from `STATIC_PATH`
fastify.register(import('@fastify/static'), {
root: STATIC_PATH,
prefix: '/static/',
});
// [GET] /
fastify.get('/', index);
// [GET] /check
fastify.get('/check', check);
// [POST] /
fastify.post('/', capture);
// [GET] /api/v1/hashes/check/:hash
fastify.get('/api/v1/hashes/check/:hash', checkHash);
};

259
app/server.test.js 100644
Wyświetl plik

@ -0,0 +1,259 @@
/**
* thread-keeper
* @module server.test
* @author The Harvard Library Innovation Lab
* @license MIT
*/
import fs from "fs";
import assert from "assert";
import crypto from "crypto";
import { test } from "tap";
import Fastify from "fastify";
import isHtml from "is-html";
import { AccessKeys } from "./utils/index.js";
import server, { CAPTURES_WATCH, successLog } from "./server.js";
import { DATA_PATH, CERTS_PATH } from "./const.js";
/**
* Access keys fixture.
* @type {{active: string[], inactive: string[]}}
*/
const ACCESS_KEYS = (() => {
const rawAccessKeys = JSON.parse(fs.readFileSync(AccessKeys.filepath));
const out = { active: [], inactive: [] };
for (let [key, value] of Object.entries(rawAccessKeys)) {
value === true ? out.active.push(key) : out.inactive.push(key);
}
return out;
})();
/**
* Dummy url of a thread to capture.
*/
const THREAD_URL = "https://twitter.com/HarvardLIL/status/1595150565428039680";
test("Integration tests for server.js", async(t) => {
// Do not run tests if `CERTS_PATH` and `DATA_PATH` do not point to a `fixtures` folder
t.before((t) => {
try {
assert(DATA_PATH.includes("/fixtures/"));
assert(CERTS_PATH.includes("/fixtures/"));
}
catch(err) {
throw new Error("Test must be run against fixtures. Set CERTS_PATH and DATA_PATH env vars accordingly.");
}
});
test("[GET] / returns HTTP 200 + HTML", async (t) => {
const app = Fastify({logger: false});
await server(app, {});
const response = await app.inject({
method: "GET",
url: "/",
});
t.equal(response.statusCode, 200, "Server returns HTTP 200.");
t.type(isHtml(response.body), true, "Server serves HTML.");
});
test("[POST] / returns HTTP 401 + HTML on failed access key check.", async (t) => {
const app = Fastify({logger: false});
await server(app, {});
const scenarios = [
"FOO-BAR", // Invalid key
ACCESS_KEYS.inactive[0], // Inactive key
null // No key
]
for (const accessKey of scenarios) {
const params = new URLSearchParams();
if (accessKey) {
params.append("access-key", accessKey);
}
const response = await app.inject({
method: "POST",
url: "/",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
t.equal(response.statusCode, 401, "Server returns HTTP 401.");
const body = `${response.body}`;
t.type(isHtml(body), true, "Server serves HTML");
t.equal(body.includes(`data-reason="ACCESS-KEY"`), true, "With error message.");
}
});
test("[POST] / returns HTTP 400 + HTML on failed url check.", async (t) => {
const app = Fastify({logger: false});
await server(app, {});
const scenarios = [
"https://lil.law.harvard.edu", // Non-twitter
"twitter.com/harvardlil", // Non-url
12,
null // Nothing
]
for (const url of scenarios) {
const params = new URLSearchParams();
params.append("access-key", ACCESS_KEYS.active[0]);
if (url) {
params.append("url", url);
}
const response = await app.inject({
method: "POST",
url: "/",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
t.equal(response.statusCode, 400, "Server returns HTTP 401.");
const body = `${response.body}`;
t.type(isHtml(body), true, "Server serves HTML");
t.equal(body.includes(`data-reason="URL"`), true, "With error message.");
}
});
test("[POST] / returns HTTP 503 + HTML on failed server capacity check.", async (t) => {
const app = Fastify({logger: false});
await server(app, {});
CAPTURES_WATCH.currentTotal = CAPTURES_WATCH.maxTotal; // Simulate peak
const params = new URLSearchParams();
params.append("access-key", ACCESS_KEYS.active[0]);
params.append("url", THREAD_URL);
const response = await app.inject({
method: "POST",
url: "/",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
t.equal(response.statusCode, 503, "Server returns HTTP 503");
const body = `${response.body}`;
t.type(isHtml(body), true, "Server serves HTML");
t.equal(body.includes(`TOO-MANY-CAPTURES-TOTAL`), true, "With error message.");
CAPTURES_WATCH.currentTotal = 0;
});
test("[POST] / returns HTTP 429 + HTML on user over parallel capture allowance.", async (t) => {
const app = Fastify({logger: false});
await server(app, {});
const userKey = ACCESS_KEYS.active[0];
CAPTURES_WATCH.currentByAccessKey[userKey] = CAPTURES_WATCH.maxPerAccessKey;
const params = new URLSearchParams();
params.append("access-key", userKey);
params.append("url", THREAD_URL);
const response = await app.inject({
method: "POST",
url: "/",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
t.equal(response.statusCode, 429, "Server returns HTTP 503.");
const body = `${response.body}`;
t.type(isHtml(body), true, "Server serves HTML");
t.equal(body.includes(`TOO-MANY-CAPTURES-USER`), true, "With error message.");
delete CAPTURES_WATCH.currentByAccessKey[userKey];
});
test("[POST] / returns HTTP 200 + PDF", async (t) => {
const app = Fastify({logger: false});
await server(app, {});
const params = new URLSearchParams();
params.append("access-key", ACCESS_KEYS.active[0]);
params.append("url", THREAD_URL);
params.append("unfold-thread", "on");
const response = await app.inject({
method: "POST",
url: "/",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
t.equal(response.statusCode, 200, "Server returns HTTP 200.");
t.equal(response.headers["content-type"], "application/pdf");
t.equal(response.headers["content-disposition"].startsWith("attachment"), true);
t.equal(response.body.substring(0, 8), "%PDF-1.7", "Server returns a PDF as attachment.");
// Check filename processing
const contentDisposition = response.headers["content-disposition"];
const filename = contentDisposition.substring(22, contentDisposition.length - 1);
t.match(filename, /^twitter-com-[a-z0-9-]+-[0-9]{4}-[0-9]{2}-[0-9]{2}\.pdf$/);
});
test("[GET] /check returns HTTP 200 + HTML", async (t) => {
const app = Fastify({logger: false});
await server(app, {});
const response = await app.inject({
method: "GET",
url: "/check",
});
t.equal(response.statusCode, 200, "Server returns HTTP 200.");
t.type(isHtml(response.body), true, "Server serves HTML.");
});
test("[GET] /api/v1/hashes/check/<sha512-hash> returns HTTP 404 on failed hash check.", async (t) => {
const app = Fastify({logger: false});
await server(app, {});
const randomHash = crypto.createHash('sha512').update(Buffer.from("HELLO WORLD")).digest('base64');
const response = await app.inject({
method: "GET",
url: `/api/v1/hashes/check/${encodeURIComponent(randomHash)}`,
});
t.equal(response.statusCode, 404, "Server returns HTTP 404.");
});
test("[GET] /api/v1/hashes/check/<sha512-hash> returns HTTP 200 on successful hash check.", async (t) => {
const app = Fastify({logger: false});
await server(app, {});
// Add entry to success logs
const toHash = Buffer.from(`${Date.now()}`);
const hash = crypto.createHash('sha512').update(toHash).digest('base64');
successLog.add(ACCESS_KEYS.active[0], toHash);
const response = await app.inject({
method: "GET",
url: `/api/v1/hashes/check/${encodeURIComponent(hash)}`,
});
t.equal(response.statusCode, 200, "Server returns HTTP 200.");
});
});

Wyświetl plik

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Page not found</title>
<meta name="description" content="Page not found">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/index.css">
<script type="module" src="/static/check.js"></script>
</head>
<body id="not-found">
<main>
{% include "./modules/header-pic.njk" %}
<header>
<h1>404 Not found</h1>
<p>The page you requested could not be found.</p>
<p><a href="/">Go back to the homepage</a></p>
</header>
</main>
</body>
</html>

Wyświetl plik

@ -43,8 +43,8 @@
<fieldset class="submit">
<span>
<input type="checkbox" name="auto-scroll" id="auto-scroll"/>
<label for="auto-scroll">Unfold thread</label>
<input type="checkbox" name="unfold-thread" id="unfold-thread"/>
<label for="unfold-thread">Unfold thread</label>
</span>
<button>Capture</button>
@ -63,7 +63,7 @@
</dialog>
{% if error %}
<dialog id="form-error">
<dialog id="form-error" data-reason="{{ errorReason }}">
<h2>Something went wrong</h2>
{% if errorReason and errorReason == "ACCESS-KEY" %}
@ -103,7 +103,9 @@
<p>There are lots of screenshots of Twitter threads going around. Some are real, some are fake. You can't tell who made them, or when they were made.</p>
<p>PDFs let us apply document signatures and timestamps so anyone can check, in the future, that a PDF you download with this site really came from the Harvard Library Innovation Lab and hasn't been edited. PDFs also let us bundle in additional media as attachments. Each signed PDF currently includes all images in the page (so you can see full size images that are cropped in the PDF view) and the primary video on the page if any.</p>
<p>PDFs let us apply document signatures and timestamps so anyone can check, in the future, that a PDF you download with this site really came from the Harvard Library Innovation Lab and hasn't been edited.</p>
<p>PDFs also let us bundle in additional media as attachments. Each signed PDF currently includes all images in the page (so you can see full size images that are cropped in the PDF view), the primary video on the page if any, as well as a list of all the t.co links on the thread and their actual destination.</p>
<h2>Why <em>not</em> make a PDF archiving tool for Twitter?</h2>

Wyświetl plik

@ -16,14 +16,12 @@ import { DATA_PATH } from "../const.js";
* [!] For alpha launch only.
*/
export class AccessKeys {
/**
* Complete path to `access-keys.json`.
* @type {string}
*/
static filepath = `${DATA_PATH}access-keys.json`;
/**
* Frozen hashmap of available access keys
* (app needs to be restarted for new keys to be taken into account, for now).

Wyświetl plik

@ -10,8 +10,9 @@ import { chromium } from "playwright";
import { v4 as uuidv4 } from "uuid";
import { PDFDocument } from "pdf-lib";
import nunjucks from "nunjucks";
import { CERTS_PATH, TMP_PATH, EXECUTABLES_FOLDER } from "../const.js";
import { CERTS_PATH, TMP_PATH, EXECUTABLES_FOLDER, TEMPLATES_PATH, APP_VERSION } from "../const.js";
/**
* Generates a "sealed" PDF out of a twitter.com url using Playwright.
@ -26,10 +27,12 @@ import { CERTS_PATH, TMP_PATH, EXECUTABLES_FOLDER } from "../const.js";
export class TwitterCapture {
/**
* Defaults for options that can be passed to `TwitterCapture`.
* @property {string} appVersion
* @property {string} privateKeyPath - Path to `.pem` file containing a private key.
* @property {string} certPath - Path to a `.pem` file containing a certificate.
* @property {string} tmpFolderPath - Path to a folder in which temporary file can be written.
* @property {string} ytDlpPath - Path to the `yt-dlp` executable.
* @property {string} templatesFolderPath - Path to the templates folder (t.co resolver summary feature).
* @property {string} timestampServerUrl - Timestamping server.
* @property {number} networkidleTimeout - Time to wait for "networkidle" state.
* @property {boolean} runBrowserBehaviors - If `true`, will try to auto-scroll and open more responses. Set to `false` automatically when trying to capture a profile url.
@ -38,9 +41,11 @@ export class TwitterCapture {
* @property {number} renderTimeout - Time to wait for re-renders.
*/
static defaults = {
appVersion: APP_VERSION,
privateKeyPath: `${CERTS_PATH}key.pem`,
certPath: `${CERTS_PATH}cert.pem`,
tmpFolderPath: `${TMP_PATH}`,
templatesFolderPath: `${TEMPLATES_PATH}pdf-attachments/`,
ytDlpPath: `${EXECUTABLES_FOLDER}yt-dlp`,
timestampServerUrl: "http://timestamp.digicert.com",
networkidleTimeout: 5000,
@ -145,24 +150,27 @@ export class TwitterCapture {
rawPDF = await this.generateRawPDF();
editablePDF = await PDFDocument.load(rawPDF);
// Add intercepted JPEGs as attachments
await this.addInterceptedJPEGsToPDF(editablePDF);
// Remove extraneous page, add metadata
try {
editablePDF.setTitle(`Capture of ${this.url} by thread-keeper on ${new Date().toISOString()}`);
editablePDF.setCreationDate(new Date());
editablePDF.setModificationDate(new Date());
editablePDF.setProducer("thread-keeper");
editablePDF.removePage(1);
editablePDF.setProducer(`thread-keeper ${this.options.appVersion}`);
editablePDF.removePage(1); // This step may throw if there's only 1 page.
}
catch {
//console.log(err);
catch {
// console.log(error);
}
// Try to crop remaining white space
await this.cropMarginsOnPDF(editablePDF);
// Add intercepted JPEGs as attachments
await this.addInterceptedJPEGsToPDF(editablePDF);
// Try to capture t.co to full urls map, and add it as attachment
await this.captureAndAddUrlMapToPDF(editablePDF);
// Try to capture video, if any, and add it as attachment
await this.captureAndAddVideoToPDF(editablePDF);
@ -327,6 +335,7 @@ export class TwitterCapture {
}
catch(err) {
// Ignore behavior errors.
// console.log(err);
}
}
@ -439,6 +448,69 @@ export class TwitterCapture {
}
}
/**
* Tries to list and resolve all the `t.co` urls on the page, and add the resulting map as an attachment.
*
* Attachment filename: `url-map.csv`.
* Playwright needs to be ready.
*
* @param {PDFDocument} - Editable PDF object from `pdf-lib`.
* @returns {Promise<void>}
*/
captureAndAddUrlMapToPDF = async(editablePDF) => {
if (this.playwright.ready !== true) {
throw new Error("Playwright is not ready.");
}
/** @type {object<string, boolean|string>} */
const map = {};
const filename = "url-map.csv";
let output = "";
// Capture urls to resolve
const shortUrls = await this.playwright.page.evaluate(() => {
const urls = {};
for (let a of document.querySelectorAll("a[href^='https://t.co']")) {
urls[a.getAttribute("href")] = true;
}
return Object.keys(urls);
});
if (shortUrls.length < 1) {
return;
}
for (const url of shortUrls) {
map[url] = false;
}
// Try to resolve urls (in parallel)
async function resolveShortUrl(url) {
try {
const response = await fetch(url, { method: "HEAD" });
map[url] = response.url;
}
catch(err) { /* console.log(err); */}
}
await Promise.allSettled(shortUrls.map(url => resolveShortUrl(url)));
// Generate and attach CSV
output = "short;long\n";
for (let [short, long] of Object.entries(map)) {
output += `"${short}";"${long ? long : ''}"\n`;
}
await editablePDF.attach(Buffer.from(output), filename, {
mimeType: 'text/csv',
description: `t.co links from ${this.url}`,
creationDate: new Date(),
modificationDate: new Date(),
});
}
/**
* Tries to capture main video from current Twitter url and add it as attachment to the PDF.
* @param {PDFDocument} - Editable PDF object from `pdf-lib`.
@ -614,9 +686,7 @@ export class TwitterCapture {
/** @type {?string} */
let urlType = null;
//
// Determine if `url` is a valid `twitter.com` and remove known tracking params
//
try {
parsedUrl = new URL(url); // Will throw if not a valid url.
@ -632,9 +702,7 @@ export class TwitterCapture {
throw new Error(`${url} is not a valid Twitter url.`);
}
//
// Determine Twitter url "type"
//
if (parsedUrl.pathname.includes("/status/")) {
urlType = "status";
}

Wyświetl plik

@ -1,66 +1,66 @@
<a name="const.module_js"></a>
<a name="module_const"></a>
## js
## const
thread-keeper
**Author**: The Harvard Library Innovation Lab
**License**: MIT
* [js](#const.module_js)
* [.CERTS_PATH](#const.module_js.CERTS_PATH)
* [.DATA_PATH](#const.module_js.DATA_PATH)
* [.TMP_PATH](#const.module_js.TMP_PATH)
* [.TEMPLATES_PATH](#const.module_js.TEMPLATES_PATH)
* [.EXECUTABLES_FOLDER](#const.module_js.EXECUTABLES_FOLDER)
* [.STATIC_PATH](#const.module_js.STATIC_PATH)
* [.MAX_PARALLEL_CAPTURES_TOTAL](#const.module_js.MAX_PARALLEL_CAPTURES_TOTAL)
* [.MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY](#const.module_js.MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY)
* [const](#module_const)
* [.CERTS_PATH](#module_const.CERTS_PATH)
* [.DATA_PATH](#module_const.DATA_PATH)
* [.TMP_PATH](#module_const.TMP_PATH)
* [.TEMPLATES_PATH](#module_const.TEMPLATES_PATH)
* [.EXECUTABLES_FOLDER](#module_const.EXECUTABLES_FOLDER)
* [.STATIC_PATH](#module_const.STATIC_PATH)
* [.MAX_PARALLEL_CAPTURES_TOTAL](#module_const.MAX_PARALLEL_CAPTURES_TOTAL)
* [.MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY](#module_const.MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY)
<a name="const.module_js.CERTS_PATH"></a>
<a name="module_const.CERTS_PATH"></a>
### js.CERTS\_PATH
### const.CERTS\_PATH
Path to the folder holding the certificates used for signing the PDFs.
**Kind**: static constant of [<code>js</code>](#const.module_js)
<a name="const.module_js.DATA_PATH"></a>
**Kind**: static constant of [<code>const</code>](#module_const)
<a name="module_const.DATA_PATH"></a>
### js.DATA\_PATH
### const.DATA\_PATH
Path to the "data" folder.
**Kind**: static constant of [<code>js</code>](#const.module_js)
<a name="const.module_js.TMP_PATH"></a>
**Kind**: static constant of [<code>const</code>](#module_const)
<a name="module_const.TMP_PATH"></a>
### js.TMP\_PATH
### const.TMP\_PATH
Path to the folder in which temporary files will be written by the app.
**Kind**: static constant of [<code>js</code>](#const.module_js)
<a name="const.module_js.TEMPLATES_PATH"></a>
**Kind**: static constant of [<code>const</code>](#module_const)
<a name="module_const.TEMPLATES_PATH"></a>
### js.TEMPLATES\_PATH
### const.TEMPLATES\_PATH
Path to the "templates" folder.
**Kind**: static constant of [<code>js</code>](#const.module_js)
<a name="const.module_js.EXECUTABLES_FOLDER"></a>
**Kind**: static constant of [<code>const</code>](#module_const)
<a name="module_const.EXECUTABLES_FOLDER"></a>
### js.EXECUTABLES\_FOLDER
### const.EXECUTABLES\_FOLDER
Path to the "executables" folder, for dependencies that are meant to be executed directly, such as `yt-dlp`.
**Kind**: static constant of [<code>js</code>](#const.module_js)
<a name="const.module_js.STATIC_PATH"></a>
**Kind**: static constant of [<code>const</code>](#module_const)
<a name="module_const.STATIC_PATH"></a>
### js.STATIC\_PATH
### const.STATIC\_PATH
Path to the "static" folder.
**Kind**: static constant of [<code>js</code>](#const.module_js)
<a name="const.module_js.MAX_PARALLEL_CAPTURES_TOTAL"></a>
**Kind**: static constant of [<code>const</code>](#module_const)
<a name="module_const.MAX_PARALLEL_CAPTURES_TOTAL"></a>
### js.MAX\_PARALLEL\_CAPTURES\_TOTAL
### const.MAX\_PARALLEL\_CAPTURES\_TOTAL
Maximum capture processes that can be run in parallel.
**Kind**: static constant of [<code>js</code>](#const.module_js)
<a name="const.module_js.MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY"></a>
**Kind**: static constant of [<code>const</code>](#module_const)
<a name="module_const.MAX_PARALLEL_CAPTURES_PER_ACCESS_KEY"></a>
### js.MAX\_PARALLEL\_CAPTURES\_PER\_ACCESS\_KEY
### const.MAX\_PARALLEL\_CAPTURES\_PER\_ACCESS\_KEY
Maximum capture processes that can be run in parallel for a given key.
**Kind**: static constant of [<code>js</code>](#const.module_js)
**Kind**: static constant of [<code>const</code>](#module_const)

Wyświetl plik

@ -1,84 +1,93 @@
<a name="server.module_js"></a>
<a name="module_server"></a>
## js
## server
thread-keeper
**Author**: The Harvard Library Innovation Lab
**License**: MIT
* [js](#server.module_js)
* [~successLog](#server.module_js..successLog) : <code>SuccessLog</code>
* [~accessKeys](#server.module_js..accessKeys) : <code>AccessKeys</code>
* [~CAPTURES_WATCH](#server.module_js..CAPTURES_WATCH) : <code>Object</code>
* [~index(request, reply)](#server.module_js..index) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
* [~check(request, reply)](#server.module_js..check) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
* [~capture(request, reply)](#server.module_js..capture) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
* [~checkHash(request, reply)](#server.module_js..checkHash) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
* [server](#module_server)
* _static_
* [.successLog](#module_server.successLog) : <code>SuccessLog</code>
* [.CAPTURES_WATCH](#module_server.CAPTURES_WATCH) : <code>Object</code>
* _inner_
* [~accessKeys](#module_server..accessKeys) : <code>AccessKeys</code>
* [~index(request, reply)](#module_server..index) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
* [~capture(request, reply)](#module_server..capture) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
* [~check(request, reply)](#module_server..check) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
* [~checkHash(request, reply)](#module_server..checkHash) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
<a name="server.module_js..successLog"></a>
<a name="module_server.successLog"></a>
### js~successLog : <code>SuccessLog</code>
**Kind**: inner constant of [<code>js</code>](#server.module_js)
<a name="server.module_js..accessKeys"></a>
### server.successLog : <code>SuccessLog</code>
**Kind**: static constant of [<code>server</code>](#module_server)
<a name="module_server.CAPTURES_WATCH"></a>
### js~accessKeys : <code>AccessKeys</code>
**Kind**: inner constant of [<code>js</code>](#server.module_js)
<a name="server.module_js..CAPTURES_WATCH"></a>
### js~CAPTURES\_WATCH : <code>Object</code>
### server.CAPTURES\_WATCH : <code>Object</code>
Keeps track of how many capture processes are currently running.
May be used to redirect users if over capacity.
[!] Only good for early prototyping.
**Kind**: inner constant of [<code>js</code>](#server.module_js)
<a name="server.module_js..index"></a>
**Kind**: static constant of [<code>server</code>](#module_server)
<a name="module_server..accessKeys"></a>
### js~index(request, reply) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
[GET] /
Shows the landing page / form.
### server~accessKeys : <code>AccessKeys</code>
**Kind**: inner constant of [<code>server</code>](#module_server)
<a name="module_server..index"></a>
### server~index(request, reply) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
[GET] /
Shows the landing page and capture form.
Assumes `fastify` is in scope.
**Kind**: inner method of [<code>js</code>](#server.module_js)
**Kind**: inner method of [<code>server</code>](#module_server)
| Param | Type |
| --- | --- |
| request | <code>fastify.FastifyRequest</code> |
| reply | <code>fastify.FastifyReply</code> |
<a name="server.module_js..check"></a>
<a name="module_server..capture"></a>
### js~check(request, reply) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
[GET] /check
Shows the "check" page /check form. Loads certificates history files in the process.
Assumes `fastify` is in scope.
**Kind**: inner method of [<code>js</code>](#server.module_js)
| Param | Type |
| --- | --- |
| request | <code>fastify.FastifyRequest</code> |
| reply | <code>fastify.FastifyReply</code> |
<a name="server.module_js..capture"></a>
### js~capture(request, reply) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
### server~capture(request, reply) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
[POST] `/`
Processes a request to capture a `twitter.com` url.
Serves PDF bytes directly if operation is successful.
Returns to form with specific error code, passed as `errorReason`, otherwise.
Subject to captures rate limiting (see `CAPTURES_WATCH`).
Body is expected as `application/x-www-form-urlencoded` with the following fields:
- access-key
- url
- unfold-thread (optional)
Assumes `fastify` is in scope.
**Kind**: inner method of [<code>js</code>](#server.module_js)
**Kind**: inner method of [<code>server</code>](#module_server)
| Param | Type |
| --- | --- |
| request | <code>fastify.FastifyRequest</code> |
| reply | <code>fastify.FastifyReply</code> |
<a name="server.module_js..checkHash"></a>
<a name="module_server..check"></a>
### js~checkHash(request, reply) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
### server~check(request, reply) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
[GET] /check
Shows the "check" page /check form. Loads certificates history files in the process.
Assumes `fastify` is in scope.
**Kind**: inner method of [<code>server</code>](#module_server)
| Param | Type |
| --- | --- |
| request | <code>fastify.FastifyRequest</code> |
| reply | <code>fastify.FastifyReply</code> |
<a name="module_server..checkHash"></a>
### server~checkHash(request, reply) ⇒ <code>Promise.&lt;fastify.FastifyReply&gt;</code>
[GET] `/api/v1/hashes/check/<sha512-hash>`.
Checks if a given SHA512 hash is in the "success" logs, meaning this app created it.
Hash is passed as the last parameter, url encoded.
@ -86,7 +95,7 @@ Assumes `fastify` is in scope.
Returns HTTP 200 if found, HTTP 404 if not.
**Kind**: inner method of [<code>js</code>](#server.module_js)
**Kind**: inner method of [<code>server</code>](#module_server)
| Param | Type |
| --- | --- |

Wyświetl plik

@ -7,29 +7,33 @@ thread-keeper
**License**: MIT
* [TwitterCapture](#utils.module_TwitterCapture)
* [.TwitterCapture](#utils.module_TwitterCapture.TwitterCapture)
* [new exports.TwitterCapture(url, options)](#new_utils.module_TwitterCapture.TwitterCapture_new)
* [.defaults](#utils.module_TwitterCapture.TwitterCapture+defaults)
* [.options](#utils.module_TwitterCapture.TwitterCapture+options) : <code>object</code>
* [.url](#utils.module_TwitterCapture.TwitterCapture+url) : <code>string</code>
* [.urlType](#utils.module_TwitterCapture.TwitterCapture+urlType) : <code>string</code>
* [.playwright](#utils.module_TwitterCapture.TwitterCapture+playwright) : <code>Object</code>
* [.interceptedJPEGs](#utils.module_TwitterCapture.TwitterCapture+interceptedJPEGs) : <code>object.&lt;string, Buffer&gt;</code>
* [.capture](#utils.module_TwitterCapture.TwitterCapture+capture) ⇒ <code>Promise.&lt;Buffer&gt;</code>
* [.setup](#utils.module_TwitterCapture.TwitterCapture+setup) ⇒ <code>Promise.&lt;void&gt;</code>
* [.teardown](#utils.module_TwitterCapture.TwitterCapture+teardown)
* [.adjustUIForCapture](#utils.module_TwitterCapture.TwitterCapture+adjustUIForCapture) ⇒ <code>Promise.&lt;void&gt;</code>
* [.runBrowserBehaviors](#utils.module_TwitterCapture.TwitterCapture+runBrowserBehaviors) ⇒ <code>Promise.&lt;void&gt;</code>
* [.resizeViewportToFitDocument](#utils.module_TwitterCapture.TwitterCapture+resizeViewportToFitDocument) ⇒ <code>Promise.&lt;void&gt;</code>
* [.getDocumentDimensions](#utils.module_TwitterCapture.TwitterCapture+getDocumentDimensions) ⇒ <code>Promise.&lt;{width: number, height: number}&gt;</code>
* [.interceptJpegs](#utils.module_TwitterCapture.TwitterCapture+interceptJpegs) ⇒ <code>Promise.&lt;void&gt;</code>
* [.generateRawPDF](#utils.module_TwitterCapture.TwitterCapture+generateRawPDF) ⇒ <code>Promise.&lt;Buffer&gt;</code>
* [.addInterceptedJPEGsToPDF](#utils.module_TwitterCapture.TwitterCapture+addInterceptedJPEGsToPDF) ⇒ <code>Promise.&lt;void&gt;</code>
* [.captureAndAddVideoToPDF](#utils.module_TwitterCapture.TwitterCapture+captureAndAddVideoToPDF) ⇒ <code>Promise.&lt;void&gt;</code>
* [.cropMarginsOnPDF](#utils.module_TwitterCapture.TwitterCapture+cropMarginsOnPDF)
* [.signPDF](#utils.module_TwitterCapture.TwitterCapture+signPDF) ⇒ <code>Buffer</code>
* [.filterOptions](#utils.module_TwitterCapture.TwitterCapture+filterOptions)
* [.filterUrl](#utils.module_TwitterCapture.TwitterCapture+filterUrl) ⇒ <code>bool</code>
* _static_
* [.TwitterCapture](#utils.module_TwitterCapture.TwitterCapture)
* [new exports.TwitterCapture(url, options)](#new_utils.module_TwitterCapture.TwitterCapture_new)
* [.defaults](#utils.module_TwitterCapture.TwitterCapture+defaults)
* [.options](#utils.module_TwitterCapture.TwitterCapture+options) : <code>object</code>
* [.url](#utils.module_TwitterCapture.TwitterCapture+url) : <code>string</code>
* [.urlType](#utils.module_TwitterCapture.TwitterCapture+urlType) : <code>string</code>
* [.playwright](#utils.module_TwitterCapture.TwitterCapture+playwright) : <code>Object</code>
* [.interceptedJPEGs](#utils.module_TwitterCapture.TwitterCapture+interceptedJPEGs) : <code>object.&lt;string, Buffer&gt;</code>
* [.capture](#utils.module_TwitterCapture.TwitterCapture+capture) ⇒ <code>Promise.&lt;Buffer&gt;</code>
* [.setup](#utils.module_TwitterCapture.TwitterCapture+setup) ⇒ <code>Promise.&lt;void&gt;</code>
* [.teardown](#utils.module_TwitterCapture.TwitterCapture+teardown)
* [.adjustUIForCapture](#utils.module_TwitterCapture.TwitterCapture+adjustUIForCapture) ⇒ <code>Promise.&lt;void&gt;</code>
* [.runBrowserBehaviors](#utils.module_TwitterCapture.TwitterCapture+runBrowserBehaviors) ⇒ <code>Promise.&lt;void&gt;</code>
* [.resizeViewportToFitDocument](#utils.module_TwitterCapture.TwitterCapture+resizeViewportToFitDocument) ⇒ <code>Promise.&lt;void&gt;</code>
* [.getDocumentDimensions](#utils.module_TwitterCapture.TwitterCapture+getDocumentDimensions) ⇒ <code>Promise.&lt;{width: number, height: number}&gt;</code>
* [.interceptJpegs](#utils.module_TwitterCapture.TwitterCapture+interceptJpegs) ⇒ <code>Promise.&lt;void&gt;</code>
* [.generateRawPDF](#utils.module_TwitterCapture.TwitterCapture+generateRawPDF) ⇒ <code>Promise.&lt;Buffer&gt;</code>
* [.addInterceptedJPEGsToPDF](#utils.module_TwitterCapture.TwitterCapture+addInterceptedJPEGsToPDF) ⇒ <code>Promise.&lt;void&gt;</code>
* [.captureAndAddUrlMapToPDF](#utils.module_TwitterCapture.TwitterCapture+captureAndAddUrlMapToPDF) ⇒ <code>Promise.&lt;void&gt;</code>
* [.captureAndAddVideoToPDF](#utils.module_TwitterCapture.TwitterCapture+captureAndAddVideoToPDF) ⇒ <code>Promise.&lt;void&gt;</code>
* [.cropMarginsOnPDF](#utils.module_TwitterCapture.TwitterCapture+cropMarginsOnPDF)
* [.signPDF](#utils.module_TwitterCapture.TwitterCapture+signPDF) ⇒ <code>Buffer</code>
* [.filterOptions](#utils.module_TwitterCapture.TwitterCapture+filterOptions)
* [.filterUrl](#utils.module_TwitterCapture.TwitterCapture+filterUrl) ⇒ <code>bool</code>
* _inner_
* [~URL_MAP_TEMPLATE](#utils.module_TwitterCapture..URL_MAP_TEMPLATE)
<a name="utils.module_TwitterCapture.TwitterCapture"></a>
@ -63,6 +67,7 @@ fs.writeFileSync("tweet.pdf", pdf);
* [.interceptJpegs](#utils.module_TwitterCapture.TwitterCapture+interceptJpegs) ⇒ <code>Promise.&lt;void&gt;</code>
* [.generateRawPDF](#utils.module_TwitterCapture.TwitterCapture+generateRawPDF) ⇒ <code>Promise.&lt;Buffer&gt;</code>
* [.addInterceptedJPEGsToPDF](#utils.module_TwitterCapture.TwitterCapture+addInterceptedJPEGsToPDF) ⇒ <code>Promise.&lt;void&gt;</code>
* [.captureAndAddUrlMapToPDF](#utils.module_TwitterCapture.TwitterCapture+captureAndAddUrlMapToPDF) ⇒ <code>Promise.&lt;void&gt;</code>
* [.captureAndAddVideoToPDF](#utils.module_TwitterCapture.TwitterCapture+captureAndAddVideoToPDF) ⇒ <code>Promise.&lt;void&gt;</code>
* [.cropMarginsOnPDF](#utils.module_TwitterCapture.TwitterCapture+cropMarginsOnPDF)
* [.signPDF](#utils.module_TwitterCapture.TwitterCapture+signPDF) ⇒ <code>Buffer</code>
@ -92,6 +97,7 @@ Defaults for options that can be passed to `TwitterCapture`.
| certPath | <code>string</code> | Path to a `.pem` file containing a certificate. |
| tmpFolderPath | <code>string</code> | Path to a folder in which temporary file can be written. |
| ytDlpPath | <code>string</code> | Path to the `yt-dlp` executable. |
| templatesFolderPath | <code>string</code> | Path to the templates folder (t.co resolver summary feature). |
| timestampServerUrl | <code>string</code> | Timestamping server. |
| networkidleTimeout | <code>number</code> | Time to wait for "networkidle" state. |
| runBrowserBehaviors | <code>boolean</code> | If `true`, will try to auto-scroll and open more responses. Set to `false` automatically when trying to capture a profile url. |
@ -202,6 +208,20 @@ Adds entries from `this.interceptedJPEGs`
| --- | --- |
| <code>PDFDocument</code> | Editable PDF object from `pdf-lib`. |
<a name="utils.module_TwitterCapture.TwitterCapture+captureAndAddUrlMapToPDF"></a>
#### twitterCapture.captureAndAddUrlMapToPDF ⇒ <code>Promise.&lt;void&gt;</code>
Tries to list and resolve all the `t.co` urls on the page, and add the resulting map as an attachment.
Attachment filename: `url-map.html`.
Playwright needs to be ready.
**Kind**: instance property of [<code>TwitterCapture</code>](#utils.module_TwitterCapture.TwitterCapture)
| Type | Description |
| --- | --- |
| <code>PDFDocument</code> | Editable PDF object from `pdf-lib`. |
<a name="utils.module_TwitterCapture.TwitterCapture+captureAndAddVideoToPDF"></a>
#### twitterCapture.captureAndAddVideoToPDF ⇒ <code>Promise.&lt;void&gt;</code>
@ -261,3 +281,9 @@ Automatically populates `this.url` and `this.urlType`.
| --- | --- |
| url | <code>string</code> |
<a name="utils.module_TwitterCapture..URL_MAP_TEMPLATE"></a>
### TwitterCapture~URL\_MAP\_TEMPLATE
Nunjucks template used by `TwitterCapture.captureAndAddUrlMapToPDF`.
**Kind**: inner constant of [<code>TwitterCapture</code>](#utils.module_TwitterCapture)

Wyświetl plik

@ -0,0 +1,2 @@
## About this folder
This folder contains fixtures used by the testing suite.

Wyświetl plik

Wyświetl plik

Wyświetl plik

@ -0,0 +1,4 @@
{
"a9c2b7b6-0652-4207-bdce-0527ad28f3f9": true,
"60e605ec-3510-4358-a0a7-33f25b3d7b74": false
}

Wyświetl plik

@ -0,0 +1,9 @@
[
{
"from": "2022-11-22 13:07:56 UTC",
"to": "present",
"domain": "lil.law.harvard.edu",
"info": "https://search.censys.io/certificates/320ec76aeebeb86440f574f877723484ff6826bbed6e15e77f68acc8e2c0db4e",
"cert": "https://search.censys.io/certificates/320ec76aeebeb86440f574f877723484ff6826bbed6e15e77f68acc8e2c0db4e/pem"
}
]

Wyświetl plik

@ -0,0 +1,9 @@
[
{
"from": "2022-11-18 13:07:56 UTC",
"to": "present",
"domain": "timestamp.digicert.com",
"info": "https://knowledge.digicert.com/generalinformation/INFO4231.html",
"cert": "https://knowledge.digicert.com/content/dam/digicertknowledgebase/attachments/time-stamp/TSACertificate.cer"
}
]

BIN
github.png 100644

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 153 KiB

6915
package-lock.json wygenerowano

Plik diff jest za duży Load Diff

Wyświetl plik

@ -1,6 +1,6 @@
{
"name": "thread-keeper",
"version": "0.0.1",
"version": "0.0.2",
"description": "",
"main": "app.js",
"type": "module",
@ -10,7 +10,7 @@
"postinstall": "cd scripts && bash download-yt-dlp.sh && bash pip-install.sh",
"generate-dev-cert": "cd scripts && bash generate-dev-cert.sh",
"docgen": "cd scripts && bash docgen.sh",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "cd scripts && bash generate-test-cert.sh && cd .. && export CERTS_PATH='./fixtures/certs/' && export DATA_PATH='./fixtures/data/' && npx tap --reporter=list --timeout=180"
},
"dependencies": {
"@fastify/formbody": "^7.3.0",
@ -86,5 +86,10 @@
"bugs": {
"url": "https://github.com/harvard-lil/thread-keeper/issues"
},
"homepage": "https://github.com/harvard-lil/thread-keeper#readme"
"homepage": "https://github.com/harvard-lil/thread-keeper#readme",
"devDependencies": {
"is-html": "^3.0.0",
"pino-pretty": "^9.1.1",
"tap": "^16.3.2"
}
}

Wyświetl plik

@ -0,0 +1,3 @@
# [TESTS ONLY] Generates a local key pair that can be used for signing PDFs.
# Will be saved under ../fixtures/certs.
openssl req -x509 -newkey rsa:4096 -keyout ../fixtures/certs/key.pem -out ../fixtures/certs/cert.pem -days 3650 -nodes -subj /CN="thread-keeper TESTS ONLY KEY";