Archive verification info + Embed improvements + Embed Receipts UI (#125)

info panel verification/integrity additions, (fixes on #113)
- info panel: display number of hashes verified valid (and invalid), observer domain (if any), and certificate fingerprint, creation software, and package digest
- info: ensure replayTS is updated, not just replayUrl, to ensure correct stats

404 page: improved formatting on 404 page (via wabac.js)

embeds:
- new embed 'replay-with-info mode, displays replay-only with dropdown showing archival 'receipt, which includes condensed info and verification (hashes valid/invalid, observer domain, cert fingerprint)
- support 'newWindowBase' attribute for specifying base replay URL for opening new window. default: use current url if deepLink enabled, otherwise use https://replayweb.page/
use transparent for embeds
- embed dropdown/receipt styling from @Shrinks99 
- automatically setting noCache and noWebWorker for older browsers (eg. safari < 16)
- add 'hideOffscreen' attribute to enable unloading embed when offscreen

dependencies: update to wabac.js 2.13.0-beta.0

bump version to 1.7.0-beta.0

Embed receipt styling co-authored-by: @Shrinks99
This commit is contained in:
Ilya Kreymer
2022-10-01 18:22:30 -07:00
committed by GitHub
parent 4cb52b4480
commit 27a8524aac
18 changed files with 1225 additions and 315 deletions
+14 -16
View File
@@ -351,10 +351,13 @@ class ReplayWebApp extends LitElement
this.showAbout = true;
}
if (this.pageParams.get("config")) {
if (this.pageParams.has("embed")) {
if (!this.loadInfo) {
this.loadInfo = {};
}
}
if (this.pageParams.get("config")) {
try {
this.loadInfo.extraConfig = JSON.parse(this.pageParams.get("config"));
} catch (e) {
@@ -362,38 +365,33 @@ class ReplayWebApp extends LitElement
}
}
if (this.pageParams.get("baseUrlSourcePrefix")) {
this.loadInfo.extraConfig = this.loadInfo.extraConfig || {};
this.loadInfo.extraConfig.baseUrlSourcePrefix = this.pageParams.get("baseUrlSourcePrefix");
}
if (this.pageParams.get("basePageUrl")) {
if (!this.loadInfo) {
this.loadInfo = {extraConfig: {}};
}
if (!this.loadInfo.extraConfig) {
this.loadInfo.extraConfig = {};
}
this.loadInfo.extraConfig = this.loadInfo.extraConfig || {};
this.loadInfo.extraConfig.baseUrl = this.pageParams.get("basePageUrl");
}
if (this.pageParams.get("customColl")) {
if (!this.loadInfo) {
this.loadInfo = {};
}
this.loadInfo.customColl = this.pageParams.get("customColl");
}
if (this.pageParams.get("noWebWorker") === "1") {
if (!this.loadInfo) {
this.loadInfo = {};
}
this.loadInfo.noWebWorker = true;
}
if (this.pageParams.get("noCache") === "1") {
if (!this.loadInfo) {
this.loadInfo = {};
}
this.loadInfo.noCache = true;
}
if (this.pageParams.get("hideOffscreen") === "1") {
this.loadInfo.hideOffscreen = true;
}
if (IS_APP && this.sourceUrl.startsWith("file://")) {
// eslint-disable-next-line no-undef
const url = new URL(__APP_FILE_SERVE_PREFIX__);
+67 -33
View File
@@ -404,44 +404,88 @@ class CollInfo extends LitElement
`;
}
render() {
renderSource(coll) {
return html`
<div class="column is-4">
<span class="source-text"><p class="minihead">Source</p>
${coll.sourceUrl && (coll.sourceUrl.startsWith("http://") || coll.sourceUrl.startsWith("https://")) ? html`
<a href="${coll.sourceUrl}">${coll.sourceUrl}&nbsp;</a>` : html`
${coll.sourceUrl}&nbsp;`}
</span>
<a @click="${(e) => this.onCopy(e, coll.sourceUrl)}" class="copy"><fa-icon .svg="${fasCopy}"/></a>
${coll.sourceUrl && coll.sourceUrl.startsWith("googledrive://") ? html`
<p><i>(${coll.filename})</i></p>` : ""}
</div>
<div class="column is-2"><p class="minihead">Date Loaded</p>${coll.ctime ? new Date(coll.ctime).toLocaleString() : ""}</div>
<div class="column is-2"><p class="minihead">Total Size</p>${prettyBytes(Number(coll.size || 0))}</div>
`;
}
renderSummaryView() {
const coll = this.coll;
const detailed = this.detailed;
return html`
<div class="columns">
<div class="column col-title is-4">
<span class="subtitle has-text-weight-bold">
<a href="?source=${encodeURIComponent(coll.sourceUrl)}">${coll.title || coll.filename}</a>
</span>
</div>
${this.renderSource(coll)}
</div>`;
}
renderDetailed() {
const coll = this.coll;
let {numValid, numInvalid, domain, certFingerprint, datapackageHash, software} = this.coll.verify || {};
numValid = numValid || 0;
numInvalid = numInvalid || 0;
const certFingerprintUrl = certFingerprint ? `https://search.censys.io/certificates/${certFingerprint}` : "";
return html`
<div class="columns">
<div class="column col-title is-4">
<span class="subtitle has-text-weight-bold">
${detailed ? html`
${coll.title || coll.filename}
` : html`
<a href="?source=${encodeURIComponent(coll.sourceUrl)}">${coll.title || coll.filename}</a>`}
</span>
</div>
${detailed && coll.desc ? html`
${coll.desc ? html`
<div class="column">
<p class="minihead">Description</p>
${coll.desc}
</div>` : html`
`}
<div class="column is-4">
<span class="source-text"><p class="minihead">Source</p>
${coll.sourceUrl && (coll.sourceUrl.startsWith("http://") || coll.sourceUrl.startsWith("https://")) ? html`
<a href="${coll.sourceUrl}">${coll.sourceUrl}&nbsp;</a>` : html`
${coll.sourceUrl}&nbsp;`}
</span>
<div class="column"><p class="minihead">Filename</p>${coll.filename}</div>
${this.renderSource(coll)}
<a @click="${(e) => this.onCopy(e, coll.sourceUrl)}" class="copy"><fa-icon .svg="${fasCopy}"/></a>
${coll.sourceUrl && coll.sourceUrl.startsWith("googledrive://") ? html`
<p><i>(${coll.filename})</i></p>` : ""}
${domain ? html`
<div class="column">
<p class="minihead">Observed By</p>
<p>${domain}</p>
${certFingerprintUrl ? html`<span><a target="_blank" href="${certFingerprintUrl}">View Certificate</a></span>` : ""}
</div>
${detailed ? html`
<div class="column"><p class="minihead">Filename</p>${coll.filename}</div>` : html``}
` : software ? html`
<div class="column">
<p class="minihead">Created With</p>
${software || "Unknown"}
</div>
` : ""}
<div class="column is-2"><p class="minihead">Date Loaded</p>${coll.ctime ? new Date(coll.ctime).toLocaleString() : ""}</div>
<div class="column is-2"><p class="minihead">Total Size</p>${prettyBytes(Number(coll.size || 0))}</div>
<div class="column">
<p class="minihead">Validation</p>
${numValid > 0 || numInvalid > 0 ? html`
<p>${numValid} hashes verified${numInvalid ? html`, ${numInvalid} invalid` : ""}</p>` : html`
Not Available`}
</div>
<div class="column">
<p class="minihead">Package Hash</p>
${datapackageHash || "Not Available"}
</div>
${detailed ? html`
<div class="column">
<p class="minihead">Loading Mode</p>
${coll.onDemand ? "Download On-Demand" : "Fully Local"}
@@ -450,22 +494,12 @@ class CollInfo extends LitElement
<p class="minihead">Collection id</p>
${coll.coll}
</div>
` : html``}
</div>`;
}
// purge + delete buttons
//
// <div class="column">
// <button @click="${(e) => this.onPurge(true)}" class="button">Purge Cache + Full Reload</button>
// </div>
// ${this.canDelete ? html`
// <div class="column">
// <button @click="${(e) => this.onPurge(false)}" class="button is-outlined is-danger">Delete Archive</button>
// </div>` : html``}
//
render() {
return this.detailed ? this.renderDetailed() : this.renderSummaryView();
}
onCopy(event, sourceUrl) {
event.preventDefault();
+29 -3
View File
@@ -24,6 +24,7 @@ import fasMenuV from "@fortawesome/fontawesome-free/svgs/solid/ellipsis-v.svg";
import fasAngleLeft from "@fortawesome/fontawesome-free/svgs/solid/angle-left.svg";
import fasAngleRight from "@fortawesome/fontawesome-free/svgs/solid/angle-right.svg";
import { RWPEmbedReceipt } from "./embed-receipt.js";
import Split from "split.js";
@@ -61,6 +62,7 @@ class Coll extends LitElement
};
this.menuActive = false;
this.embedDropdownActive = false;
this.hasStory = false;
@@ -107,6 +109,8 @@ class Coll extends LitElement
menuActive: { type: Boolean },
embed: { type: String },
embedDropdownActive: { type: Boolean },
editable: { type: Boolean },
browsable: { type: Boolean },
clearable: { type: Boolean },
@@ -131,7 +135,7 @@ class Coll extends LitElement
this.isFullscreen = !!document.fullscreenElement;
});
if (this.embed) {
if (this.embed && this.loadInfo && this.loadInfo.hideOffscreen) {
this.observer = new IntersectionObserver((entries/*, observer*/) => {
this.isVisible = entries[0].isIntersecting;
});
@@ -289,7 +293,7 @@ class Coll extends LitElement
this.collInfo.title = this.collInfo.filename;
}
if (this.embed === "replayonly") {
if (this.embed === "replayonly" || this.embed === "replay-with-info") {
this.showSidebar = false;
}
@@ -475,6 +479,13 @@ class Coll extends LitElement
flex: auto;
}
rwp-embed-receipt {
flex-direction: column;
display: flex;
}
${RWPEmbedReceipt.embedStyles}
${Coll.replayBarStyles}`;
}
@@ -657,6 +668,7 @@ class Coll extends LitElement
} else if (this.collInfo) {
return html`
${this.renderLocationBar()}
${this.renderVerifyInfo()}
<div id="tabContents">
<div id="contents" class="is-light ${isSidebar ? "sidebar" : (isReplay ? "is-hidden" : "full-pages")}"
role="${isSidebar ? "complementary" : ""}" aria-label="${isSidebar ? "Browse Contents" : ""}">
@@ -744,7 +756,7 @@ class Coll extends LitElement
}
renderLocationBar() {
if (this.embed === "replayonly") {
if (this.embed === "replayonly" || this.embed == "replay-with-info") {
return "";
}
@@ -880,6 +892,20 @@ class Coll extends LitElement
</nav><p id="skip-replay-target" tabindex="-1" class="is-sr-only">Skipped</p>`;
}
renderVerifyInfo() {
if (this.embed !== "replay-with-info") {
return "";
}
return html`<rwp-embed-receipt
.collInfo=${this.collInfo}
url=${this.url}
ts=${this.ts}
.appLogo=${this.appLogo}
>
</rwp-embed-receipt>`;
}
dragStart() {
const replay = this.renderRoot.querySelector("wr-coll-replay");
if (replay) {
+268
View File
@@ -0,0 +1,268 @@
import btGlobe from "../assets/globe.svg";
import btAngleDoubleDown from "../assets/chevron-double-down.svg";
import btAngleDoubleUp from "../assets/chevron-double-up.svg";
import fabGithub from "@fortawesome/fontawesome-free/svgs/brands/github.svg";
import fasDownload from "@fortawesome/fontawesome-free/svgs/solid/download.svg";
import {clickOnSpacebarPress} from "./misc";
import { LitElement, html, css } from "lit";
import { tsToDate } from "./pageutils";
import prettyBytes from "pretty-bytes";
// ===========================================================================
export class RWPEmbedReceipt extends LitElement
{
constructor() {
super();
this.collInfo = null;
this.appLogo = null;
this.ts = null;
this.url = null;
this.active = false;
}
get renderRoot() {
return this;
}
static get properties() {
return {
collInfo: {type: Object},
appLogo: {type: Object},
url: {type: String},
ts: {type: String},
active: {type: Boolean }
};
}
static get embedStyles() {
return css`
rwp-embed-receipt {
display: flex;
flex-direction: column;
}
.icon {
vertical-align: text-top;
}
#embed-dropdown {
max-height: calc(100vh - 50px);
padding-top: 0;
margin-top: -0.5rem;
display: block;
z-index: 1;
pointer-events: none;
transition: all .3s linear;
transform-origin: left top;
transform: scaleY(0);
transition: all 300ms cubic-bezier(0.15, 0, 0.1, 1);
filter: drop-shadow(0px 8px 4px rgba(0, 0, 0, 0.15));
}
.dropdown.is-active #embed-dropdown {
transform: scaleY(1);
}
.embed-info-container {
width: 100%;
display: flex !important;
justify-content: center;
}
button.embed-info {
padding: 0;
background-color: white;
justify-content: space-between;
max-width: 40rem;
width: calc(100% - 1rem);
height: 42px;
border-color: #D1D5DA;
border-width: 1px;
border-style: solid;
border-radius: 999px;
display: flex;
align-items: center;
text-overflow: ellipsis;
filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.15));
transition-duration: 50ms;
transition-timing-function: ease-out;
cursor: pointer;
z-index: 2
}
button.embed-info:active {
color: initial;
}
button.embed-info:hover {
filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.2));
transform: scale(1.01);
}
button.embed-info:hover:active {
transform: translateY(0.25rem);
}
.embed-info-buttontext {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
text-align: start;
font-size: 13px;
}
.embed-info-drop {
font-size: 14px;
padding: 1rem;
padding-top: 2rem;
max-width: 38rem;
max-height: 42rem;
width: calc(100% - 2rem);
border-top-right-radius: 0px;
border-top-left-radius: 0px;
pointer-events: auto;
overflow-y: auto;
}
.embed-info-drop > p {
font-size: 14px;
color: black;
}
.embed-info-drop > h2 {
margin-bottom: 0.25rem;
font-size: 16px;
font-weight: bold;
text-transform: none;
letter-spacing: 0;
color: #24292E;
}
.embed-info-drop-statscontainer > h3 {
font-size: 12px;
color: #394146;
}
.embed-info-drop-statscontainer > p {
font-size: 14px;
color: black;
}
.embed-info-drop a {
word-break: break-all;
}
.embed-info-drop .show-hash {
word-break: break-all;
font-family: monospace;
}
.embed-globe {
margin: 0.25rem;
padding: 7px;
background-color: #0366D6;
border-radius: 9999px;
color: white;
border-width: 1px;
border-color: #D1D5DA;
border-style: solid;
line-height: 0.5em;
}`;
}
render() {
let {numValid, numInvalid, domain, certFingerprint, datapackageHash, software} = this.collInfo.verify || {};
numValid = numValid || 0;
numInvalid = numInvalid || 0;
const sourceUrl = this.collInfo.sourceUrl;
const certFingerprintUrl = certFingerprint ? `https://search.censys.io/certificates/${certFingerprint}` : "";
const dateStr = tsToDate(this.ts).toLocaleString();
return html`
<div class="dropdown mb-4 ${this.active ? "is-active" : ""}">
<div class="dropdown-trigger embed-info-container">
<button class="embed-info is-small is-rounded mt-4" aria-haspopup="true" aria-controls="embed-dropdown" @click="${this.onEmbedDrop}">
<fa-icon class="menu-logo mr-2 embed-globe" size="1rem" aria-hidden="true" .svg=${btGlobe}></fa-icon>
<span class="embed-info-buttontext">
This embed is part of a web archive. Click here to learn more.
</span>
<span class="icon is-small mr-4 ml-2">
<fa-icon title="Toggle" .svg="${this.active ? btAngleDoubleUp : btAngleDoubleDown}" aria-hidden="true"></fa-icon>
</span>
</button>
</div>
<div class="dropdown-menu embed-info-container" id="embed-dropdown" role="menu">
<div class="dropdown-content embed-info-drop">
<p class="mb-4">
Even if the original page goes offline or is changed, the content below will remain unchanged as it is loaded from a web archive.
</p>
<hr class="dropdown-divider">
<h2 mt-4">Technical Information</h2>
<div class="embed-info-drop-statscontainer mb-4">
<h3>Original URL:</h3>
<p><a target="_blank" href="${this.url}">${this.url}</a></p>
<h3 class="mt-2">Archived On:</h3>
<p>${dateStr}</p>
${domain ? html`
<h3 class="mt-2">Observed By:</h3>
<p>${domain}</p>
${certFingerprintUrl ?
html`<p><a target="_blank" href="${certFingerprintUrl}">View Certificate</a></p>` : ""}
` : software ? html`
<h3 class="mt-2">Created With:</h3>
<p>${software}</p>` : ""}
<h3 class="mt-2">Validation:</h3>
${numValid > 0 || numInvalid > 0 ? html`
<p>${numValid} hashes verified${numInvalid ? html`, ${numInvalid} invalid` : ""}</p>` : html`
<p>Not Available</p>
`}
<h3 class="mt-2">Package Hash:</h3>
<p class="show-hash">${datapackageHash}</p>
<h3 class="mt-2">Size</h3>
<p>${prettyBytes(Number(this.collInfo.size || 0))}</p>
</div>
${sourceUrl ? html`
<hr class="dropdown-divider">
<a href="${sourceUrl}" class="button" @keyup="${clickOnSpacebarPress}">
<span class="icon is-small">
<fa-icon size="1.0em" class="has-text-grey" aria-hidden="true" .svg="${fasDownload}"></fa-icon>
</span>
<span>Download Archive</span>
</a>
<p class="mt-2">This archive can then be viewed directly in your browser by loading it via <a target="_blank" href="https://replayweb.page">replayweb.page</a></p>
` : ""}
<p class="is-size-7 is-flex is-justify-content-space-between" style="margin-top: 40px">
<span>
<a class="has-text-black" target="_blank" href="https://github.com/webrecorder/replayweb.page">
<fa-icon class="menu-logo mr-1" size="1.0rem" aria-hidden="true" .svg=${this.appLogo}></fa-icon>
Powered by ReplayWeb.page
</a>
</span>
<span>
<a class="has-text-black" target="_blank" href="https://github.com/webrecorder/replayweb.page">Source Code
<fa-icon class="menu-logo ml-1" size="1.0rem" aria-hidden="true" .svg=${fabGithub}></fa-icon>
</a>
</span>
</p>
</div>
</div>
</div>
`;
}
onEmbedDrop(event) {
event.stopPropagation();
this.active = !this.active;
}
}
customElements.define("rwp-embed-receipt", RWPEmbedReceipt);
+32
View File
@@ -10,6 +10,8 @@ var scriptSrc = document.currentScript && document.currentScript.src;
var defaultReplayFile = "";
const DEFAULT_REPLAY_BASE = "https://replayweb.page/";
// ===========================================================================
class Embed extends LitElement
@@ -30,6 +32,7 @@ class Embed extends LitElement
this.coll = "";
this.paramString = null;
this.deepLink = false;
this.newWindowBase = "";
this.swInited = false;
this.embed = null;
this.reloadCount = 0;
@@ -72,6 +75,9 @@ class Embed extends LitElement
noSandbox: { type: Boolean },
noWebWorker: { type: Boolean },
noCache: { type: Boolean },
hideOffscreen: { type: Boolean },
newWindowBase: { type: String },
errorMessage: { type: String }
};
@@ -118,6 +124,23 @@ class Embed extends LitElement
this.updateFromHash();
window.addEventListener("hashchange", () => this.updateFromHash());
}
this.loadBrowserDefaults();
}
loadBrowserDefaults() {
// set defaults for older browsers (eg. Safari < 16) based on recommendations from:
// https://lil.law.harvard.edu/blog/2022/09/15/opportunities-and-challenges-of-client-side-playback/
// likely safari < 16, don't use web workers due to issues with split storage state
if (window.GestureEvent !== undefined && window.SharedWorker === undefined ){
this.noWebWorker = true;
}
// if no storage manager or estimate, don't cache
if (!navigator.storage || !navigator.storage.estimate) {
this.noCache = true;
}
}
updateFromHash() {
@@ -173,9 +196,14 @@ class Embed extends LitElement
customColl: this.coll,
config,
basePageUrl: window.location.href.split("#")[0],
baseUrlSourcePrefix: this.newWindowBase,
embed: this.embed,
};
if (!this.deepLink && !params.baseUrlSourcePrefix) {
params.baseUrlSourcePrefix = DEFAULT_REPLAY_BASE;
}
if (this.noWebWorker) {
params.noWebWorker = "1";
}
@@ -184,6 +212,10 @@ class Embed extends LitElement
params.noCache = "1";
}
if (this.hideOffscreen) {
params.hideOffscreen = "1";
}
this.paramString = new URLSearchParams(params).toString();
this.hashString = new URLSearchParams({
+2 -1
View File
@@ -101,7 +101,8 @@ class Replay extends LitElement
this.dispatchEvent(new CustomEvent("update-title", {bubbles: true, composed: true, detail}));
}
if (this.replayUrl && changedProperties.has("replayUrl")) {
if ((this.replayUrl && changedProperties.has("replayUrl")) ||
(this.replayTS && changedProperties.has("replayTS"))) {
const data = {
url: this.replayUrl,
ts: this.replayTS,