Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* globals process, require */
this.shot = (function () {
let exports = {}; // Note: in this library we can't use any "system" dependencies because this can be used from multiple
// environments
const isNode =
typeof process !== "undefined" &&
Object.prototype.toString.call(process) === "[object process]";
const URL = (isNode && require("url").URL) || window.URL;
/** Throws an error if the condition isn't true. Any extra arguments after the condition
are used as console.error() arguments. */
function assert(condition, ...args) {
if (condition) {
return;
}
console.error("Failed assertion", ...args);
throw new Error(`Failed assertion: ${args.join(" ")}`);
}
/** True if `url` is a valid URL */
function isUrl(url) {
try {
const parsed = new URL(url);
if (parsed.protocol === "view-source:") {
return isUrl(url.substr("view-source:".length));
}
return true;
} catch (e) {
return false;
}
}
function isValidClipImageUrl(url) {
return isUrl(url) && !(url.indexOf(")") > -1);
}
function assertUrl(url) {
if (!url) {
throw new Error("Empty value is not URL");
}
if (!isUrl(url)) {
const exc = new Error("Not a URL");
exc.scheme = url.split(":")[0];
throw exc;
}
}
function isSecureWebUri(url) {
return isUrl(url) && url.toLowerCase().startsWith("https");
}
function assertOrigin(url) {
assertUrl(url);
if (url.search(/^https?:/i) !== -1) {
let newUrl = new URL(url);
if (newUrl.pathname != "/") {
throw new Error("Bad origin, might include path");
}
}
}
function originFromUrl(url) {
if (!url) {
return null;
}
if (url.search(/^https?:/i) === -1) {
// Non-HTTP URLs don't have an origin
return null;
}
try {
let tryUrl = new URL(url);
return tryUrl.origin;
} catch {
return null;
}
}
/** Check if the given object has all of the required attributes, and no extra
attributes exception those in optional */
function checkObject(obj, required, optional) {
if (typeof obj !== "object" || obj === null) {
throw new Error(
"Cannot check non-object: " +
typeof obj +
" that is " +
JSON.stringify(obj)
);
}
required = required || [];
for (const attr of required) {
if (!(attr in obj)) {
return false;
}
}
optional = optional || [];
for (const attr in obj) {
if (!required.includes(attr) && !optional.includes(attr)) {
return false;
}
}
return true;
}
/** Create a JSON object from a normal object, given the required and optional
attributes (filtering out any other attributes). Optional attributes are
only kept when they are truthy. */
function jsonify(obj, required, optional) {
required = required || [];
const result = {};
for (const attr of required) {
result[attr] = obj[attr];
}
optional = optional || [];
for (const attr of optional) {
if (obj[attr]) {
result[attr] = obj[attr];
}
}
return result;
}
/** True if the two objects look alike. Null, undefined, and absent properties
are all treated as equivalent. Traverses objects and arrays */
function deepEqual(a, b) {
if ((a === null || a === undefined) && (b === null || b === undefined)) {
return true;
}
if (typeof a !== "object" || typeof b !== "object") {
return a === b;
}
if (Array.isArray(a)) {
if (!Array.isArray(b)) {
return false;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
}
if (Array.isArray(b)) {
return false;
}
const seen = new Set();
for (const attr of Object.keys(a)) {
if (!deepEqual(a[attr], b[attr])) {
return false;
}
seen.add(attr);
}
for (const attr of Object.keys(b)) {
if (!seen.has(attr)) {
if (!deepEqual(a[attr], b[attr])) {
return false;
}
}
}
return true;
}
function makeRandomId() {
// Note: this isn't for secure contexts, only for non-conflicting IDs
let id = "";
while (id.length < 12) {
let num;
if (!id) {
num = Date.now() % Math.pow(36, 3);
} else {
num = Math.floor(Math.random() * Math.pow(36, 3));
}
id += num.toString(36);
}
return id;
}
class AbstractShot {
constructor(backend, id, attrs) {
attrs = attrs || {};
assert(
/^[a-zA-Z0-9]{1,4000}\/[a-z0-9._-]{1,4000}$/.test(id),
"Bad ID (should be alphanumeric):",
JSON.stringify(id)
);
this._backend = backend;
this._id = id;
this.origin = attrs.origin || null;
this.fullUrl = attrs.fullUrl || null;
if (!attrs.fullUrl && attrs.url) {
console.warn("Received deprecated attribute .url");
this.fullUrl = attrs.url;
}
if (this.origin && !isSecureWebUri(this.origin)) {
this.origin = "";
}
if (this.fullUrl && !isSecureWebUri(this.fullUrl)) {
this.fullUrl = "";
}
this.docTitle = attrs.docTitle || null;
this.userTitle = attrs.userTitle || null;
this.createdDate = attrs.createdDate || Date.now();
this.siteName = attrs.siteName || null;
this.images = [];
if (attrs.images) {
this.images = attrs.images.map(json => new this.Image(json));
}
this.openGraph = attrs.openGraph || null;
this.twitterCard = attrs.twitterCard || null;
this.documentSize = attrs.documentSize || null;
this.thumbnail = attrs.thumbnail || null;
this.abTests = attrs.abTests || null;
this.firefoxChannel = attrs.firefoxChannel || null;
this._clips = {};
if (attrs.clips) {
for (const clipId in attrs.clips) {
const clip = attrs.clips[clipId];
this._clips[clipId] = new this.Clip(this, clipId, clip);
}
}
const isProd =
typeof process !== "undefined" && process.env.NODE_ENV === "production";
for (const attr in attrs) {
if (
attr !== "clips" &&
attr !== "id" &&
!this.REGULAR_ATTRS.includes(attr) &&
!this.DEPRECATED_ATTRS.includes(attr)
) {
if (isProd) {
console.warn("Unexpected attribute: " + attr);
} else {
throw new Error("Unexpected attribute: " + attr);
}
} else if (attr === "id") {
console.warn("passing id in attrs in AbstractShot constructor");
console.trace();
assert(attrs.id === this.id);
}
}
}
/** Update any and all attributes in the json object, with deep updating
of `json.clips` */
update(json) {
const ALL_ATTRS = ["clips"].concat(this.REGULAR_ATTRS);
assert(
checkObject(json, [], ALL_ATTRS),
"Bad attr to new Shot():",
Object.keys(json)
);
for (const attr in json) {
if (attr === "clips") {
continue;
}
if (
typeof json[attr] === "object" &&
typeof this[attr] === "object" &&
this[attr] !== null
) {
let val = this[attr];
if (val.toJSON) {
val = val.toJSON();
}
if (!deepEqual(json[attr], val)) {
this[attr] = json[attr];
}
} else if (json[attr] !== this[attr] && (json[attr] || this[attr])) {
this[attr] = json[attr];
}
}
if (json.clips) {
for (const clipId in json.clips) {
if (!json.clips[clipId]) {
this.delClip(clipId);
} else if (!this.getClip(clipId)) {
this.setClip(clipId, json.clips[clipId]);
} else if (
!deepEqual(this.getClip(clipId).toJSON(), json.clips[clipId])
) {
this.setClip(clipId, json.clips[clipId]);
}
}
}
}
/** Returns a JSON version of this shot */
toJSON() {
const result = {};
for (const attr of this.REGULAR_ATTRS) {
let val = this[attr];
if (val && val.toJSON) {
val = val.toJSON();
}
result[attr] = val;
}
result.clips = {};
for (const attr in this._clips) {
result.clips[attr] = this._clips[attr].toJSON();
}
return result;
}
/** A more minimal JSON representation for creating indexes of shots */
asRecallJson() {
const result = { clips: {} };
for (const attr of this.RECALL_ATTRS) {
let val = this[attr];
if (val && val.toJSON) {
val = val.toJSON();
}
result[attr] = val;
}
for (const name of this.clipNames()) {
result.clips[name] = this.getClip(name).toJSON();
}
return result;
}
get backend() {
return this._backend;
}
get id() {
return this._id;
}
get url() {
return this.fullUrl || this.origin;
}
set url(val) {
throw new Error(".url is read-only");
}
get fullUrl() {
return this._fullUrl;
}
set fullUrl(val) {
if (val) {
assertUrl(val);
}
this._fullUrl = val || undefined;
}
get origin() {
return this._origin;
}
set origin(val) {
if (val) {
assertOrigin(val);
}
this._origin = val || undefined;
}
get isOwner() {
return this._isOwner;
}
set isOwner(val) {
this._isOwner = val || undefined;
}
get filename() {
let filenameTitle = this.title;
const date = new Date(this.createdDate);
/* eslint-disable no-control-regex */
filenameTitle = filenameTitle
.replace(/[\\/]/g, "_")
.replace(/[\u200e\u200f\u202a-\u202e]/g, "")
.replace(/[\x00-\x1f\x7f-\x9f:*?|"<>;,+=\[\]]+/g, " ")
.replace(/^[\s\u180e.]+|[\s\u180e.]+$/g, "");
/* eslint-enable no-control-regex */
filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " ");
const currentDateTime = new Date(
date.getTime() - date.getTimezoneOffset() * 60 * 1000
).toISOString();
const filenameDate = currentDateTime.substring(0, 10);
const filenameTime = currentDateTime.substring(11, 19).replace(/:/g, "-");
let clipFilename = `Screenshot ${filenameDate} at ${filenameTime} ${filenameTitle}`;
// Crop the filename size at less than 246 bytes, so as to leave
// room for the extension and an ellipsis [...]. Note that JS
// strings are UTF16 but the filename will be converted to UTF8
// when saving which could take up more space, and we want a
// maximum of 255 bytes (not characters). Here, we iterate
// and crop at shorter and shorter points until we fit into
// 255 bytes.
let suffix = "";
for (let cropSize = 246; cropSize >= 0; cropSize -= 32) {
if (new Blob([clipFilename]).size > 246) {
clipFilename = clipFilename.substring(0, cropSize);
suffix = "[...]";
} else {
break;
}
}
clipFilename += suffix;
const clip = this.getClip(this.clipNames()[0]);
let extension = ".png";
if (clip && clip.image && clip.image.type) {
if (clip.image.type === "jpeg") {
extension = ".jpg";
}
}
return clipFilename + extension;
}
get urlDisplay() {
if (!this.url) {
return null;
}
if (/^https?:\/\//i.test(this.url)) {
let txt = this.url;
txt = txt.replace(/^[a-z]{1,4000}:\/\//i, "");
txt = txt.replace(/\/.{0,4000}/, "");
txt = txt.replace(/^www\./i, "");
return txt;
} else if (this.url.startsWith("data:")) {
return "data:url";
}
let txt = this.url;
txt = txt.replace(/\?.{0,4000}/, "");
return txt;
}
get viewUrl() {
const url = this.backend + "/" + this.id;
return url;
}
get creatingUrl() {
let url = `${this.backend}/creating/${this.id}`;
url += `?title=${encodeURIComponent(this.title || "")}`;
url += `&url=${encodeURIComponent(this.url)}`;
return url;
}
get jsonUrl() {
return this.backend + "/data/" + this.id;
}
get oembedUrl() {
return this.backend + "/oembed?url=" + encodeURIComponent(this.viewUrl);
}
get docTitle() {
return this._title;
}
set docTitle(val) {
assert(val === null || typeof val === "string", "Bad docTitle:", val);
this._title = val;
}
get openGraph() {
return this._openGraph || null;
}
set openGraph(val) {
assert(val === null || typeof val === "object", "Bad openGraph:", val);
if (val) {
assert(
checkObject(val, [], this._OPENGRAPH_PROPERTIES),
"Bad attr to openGraph:",
Object.keys(val)
);
this._openGraph = val;
} else {
this._openGraph = null;
}
}
get twitterCard() {
return this._twitterCard || null;
}
set twitterCard(val) {
assert(val === null || typeof val === "object", "Bad twitterCard:", val);
if (val) {
assert(
checkObject(val, [], this._TWITTERCARD_PROPERTIES),
"Bad attr to twitterCard:",
Object.keys(val)
);
this._twitterCard = val;
} else {
this._twitterCard = null;
}
}
get userTitle() {
return this._userTitle;
}
set userTitle(val) {
assert(val === null || typeof val === "string", "Bad userTitle:", val);
this._userTitle = val;
}
get title() {
// FIXME: we shouldn't support both openGraph.title and ogTitle
const ogTitle = this.openGraph && this.openGraph.title;
const twitterTitle = this.twitterCard && this.twitterCard.title;
let title =
this.userTitle || ogTitle || twitterTitle || this.docTitle || this.url;
if (Array.isArray(title)) {
title = title[0];
}
if (!title) {
title = "Screenshot";
}
return title;
}
get createdDate() {
return this._createdDate;
}
set createdDate(val) {
assert(val === null || typeof val === "number", "Bad createdDate:", val);
this._createdDate = val;
}
clipNames() {
const names = Object.getOwnPropertyNames(this._clips);
names.sort(function (a, b) {
return a.sortOrder < b.sortOrder ? 1 : 0;
});
return names;
}
getClip(name) {
return this._clips[name];
}
addClip(val) {
const name = makeRandomId();
this.setClip(name, val);
return name;
}
setClip(name, val) {
const clip = new this.Clip(this, name, val);
this._clips[name] = clip;
}
delClip(name) {
if (!this._clips[name]) {
throw new Error("No existing clip with id: " + name);
}
delete this._clips[name];
}
delAllClips() {
this._clips = {};
}
biggestClipSortOrder() {
let biggest = 0;
for (const clipId in this._clips) {
biggest = Math.max(biggest, this._clips[clipId].sortOrder);
}
return biggest;
}
updateClipUrl(clipId, clipUrl) {
const clip = this.getClip(clipId);
if (clip && clip.image) {
clip.image.url = clipUrl;
} else {
console.warn("Tried to update the url of a clip with no image:", clip);
}
}
get siteName() {
return this._siteName || null;
}
set siteName(val) {
assert(typeof val === "string" || !val);
this._siteName = val;
}
get documentSize() {
return this._documentSize;
}
set documentSize(val) {
assert(typeof val === "object" || !val);
if (val) {
assert(
checkObject(
val,
["height", "width"],
"Bad attr to documentSize:",
Object.keys(val)
)
);
assert(typeof val.height === "number");
assert(typeof val.width === "number");
this._documentSize = val;
} else {
this._documentSize = null;
}
}
get thumbnail() {
return this._thumbnail;
}
set thumbnail(val) {
assert(typeof val === "string" || !val);
if (val) {
assert(isUrl(val));
this._thumbnail = val;
} else {
this._thumbnail = null;
}
}
get abTests() {
return this._abTests;
}
set abTests(val) {
if (val === null || val === undefined) {
this._abTests = null;
return;
}
assert(
typeof val === "object",
"abTests should be an object, not:",
typeof val
);
assert(!Array.isArray(val), "abTests should not be an Array");
for (const name in val) {
assert(
val[name] && typeof val[name] === "string",
`abTests.${name} should be a string:`,
typeof val[name]
);
}
this._abTests = val;
}
get firefoxChannel() {
return this._firefoxChannel;
}
set firefoxChannel(val) {
if (val === null || val === undefined) {
this._firefoxChannel = null;
return;
}
assert(
typeof val === "string",
"firefoxChannel should be a string, not:",
typeof val
);
this._firefoxChannel = val;
}
}
AbstractShot.prototype.REGULAR_ATTRS = `
origin fullUrl docTitle userTitle createdDate images
siteName openGraph twitterCard documentSize
thumbnail abTests firefoxChannel
`.split(/\s+/g);
// Attributes that will be accepted in the constructor, but ignored/dropped
AbstractShot.prototype.DEPRECATED_ATTRS = `
microdata history ogTitle createdDevice head body htmlAttrs bodyAttrs headAttrs
readable hashtags comments showPage isPublic resources url
fullScreenThumbnail favicon
`.split(/\s+/g);
AbstractShot.prototype.RECALL_ATTRS = `
url docTitle userTitle createdDate openGraph twitterCard images thumbnail
`.split(/\s+/g);
AbstractShot.prototype._OPENGRAPH_PROPERTIES = `
title type url image audio description determiner locale site_name video
image:secure_url image:type image:width image:height
video:secure_url video:type video:width image:height
audio:secure_url audio:type
article:published_time article:modified_time article:expiration_time article:author article:section article:tag
book:author book:isbn book:release_date book:tag
profile:first_name profile:last_name profile:username profile:gender
`.split(/\s+/g);
AbstractShot.prototype._TWITTERCARD_PROPERTIES = `
card site title description image
player player:width player:height player:stream player:stream:content_type
`.split(/\s+/g);
/** Represents one found image in the document (not a clip) */
class _Image {
// FIXME: either we have to notify the shot of updates, or make
// this read-only
constructor(json) {
assert(typeof json === "object", "Clip Image given a non-object", json);
assert(
checkObject(json, ["url"], ["dimensions", "title", "alt"]),
"Bad attrs for Image:",
Object.keys(json)
);
assert(isUrl(json.url), "Bad Image url:", json.url);
this.url = json.url;
assert(
!json.dimensions ||
(typeof json.dimensions.x === "number" &&
typeof json.dimensions.y === "number"),
"Bad Image dimensions:",
json.dimensions
);
this.dimensions = json.dimensions;
assert(
typeof json.title === "string" || !json.title,
"Bad Image title:",
json.title
);
this.title = json.title;
assert(
typeof json.alt === "string" || !json.alt,
"Bad Image alt:",
json.alt
);
this.alt = json.alt;
}
toJSON() {
return jsonify(this, ["url"], ["dimensions"]);
}
}
AbstractShot.prototype.Image = _Image;
/** Represents a clip, either a text or image clip */
class _Clip {
constructor(shot, id, json) {
this._shot = shot;
assert(
checkObject(json, ["createdDate", "image"], ["sortOrder"]),
"Bad attrs for Clip:",
Object.keys(json)
);
assert(typeof id === "string" && id, "Bad Clip id:", id);
this._id = id;
this.createdDate = json.createdDate;
if ("sortOrder" in json) {
assert(
typeof json.sortOrder === "number" || !json.sortOrder,
"Bad Clip sortOrder:",
json.sortOrder
);
}
if ("sortOrder" in json) {
this.sortOrder = json.sortOrder;
} else {
const biggestOrder = shot.biggestClipSortOrder();
this.sortOrder = biggestOrder + 100;
}
this.image = json.image;
}
toString() {
return `[Shot Clip id=${this.id} sortOrder=${this.sortOrder} image ${this.image.dimensions.x}x${this.image.dimensions.y}]`;
}
toJSON() {
return jsonify(this, ["createdDate"], ["sortOrder", "image"]);
}
get id() {
return this._id;
}
get createdDate() {
return this._createdDate;
}
set createdDate(val) {
assert(typeof val === "number" || !val, "Bad Clip createdDate:", val);
this._createdDate = val;
}
get image() {
return this._image;
}
set image(image) {
if (!image) {
this._image = undefined;
return;
}
assert(
checkObject(
image,
["url"],
["dimensions", "text", "location", "captureType", "type"]
),
"Bad attrs for Clip Image:",
Object.keys(image)
);
assert(isValidClipImageUrl(image.url), "Bad Clip image URL:", image.url);
assert(
image.captureType === "madeSelection" ||
image.captureType === "selection" ||
image.captureType === "visible" ||
image.captureType === "auto" ||
image.captureType === "fullPage" ||
image.captureType === "fullPageTruncated" ||
!image.captureType,
"Bad image.captureType:",
image.captureType
);
assert(
typeof image.text === "string" || !image.text,
"Bad Clip image text:",
image.text
);
if (image.dimensions) {
assert(
typeof image.dimensions.x === "number" &&
typeof image.dimensions.y === "number",
"Bad Clip image dimensions:",
image.dimensions
);
}
if (image.type) {
assert(
image.type === "png" || image.type === "jpeg",
"Unexpected image type:",
image.type
);
}
assert(
image.location &&
typeof image.location.left === "number" &&
typeof image.location.right === "number" &&
typeof image.location.top === "number" &&
typeof image.location.bottom === "number",
"Bad Clip image pixel location:",
image.location
);
if (
image.location.topLeftElement ||
image.location.topLeftOffset ||
image.location.bottomRightElement ||
image.location.bottomRightOffset
) {
assert(
typeof image.location.topLeftElement === "string" &&
image.location.topLeftOffset &&
typeof image.location.topLeftOffset.x === "number" &&
typeof image.location.topLeftOffset.y === "number" &&
typeof image.location.bottomRightElement === "string" &&
image.location.bottomRightOffset &&
typeof image.location.bottomRightOffset.x === "number" &&
typeof image.location.bottomRightOffset.y === "number",
"Bad Clip image element location:",
image.location
);
}
this._image = image;
}
isDataUrl() {
if (this.image) {
return this.image.url.startsWith("data:");
}
return false;
}
get sortOrder() {
return this._sortOrder || null;
}
set sortOrder(val) {
assert(typeof val === "number" || !val, "Bad Clip sortOrder:", val);
this._sortOrder = val;
}
}
AbstractShot.prototype.Clip = _Clip;
if (typeof exports !== "undefined") {
exports.AbstractShot = AbstractShot;
exports.originFromUrl = originFromUrl;
exports.isValidClipImageUrl = isValidClipImageUrl;
}
return exports;
})();
null;