parent
3bdca12a21
commit
e7dd215f4d
|
@ -16,7 +16,7 @@
|
|||
"dev": "nodemon --inspect src/index.js --debug --no-open",
|
||||
"fix": "common-good fix",
|
||||
"start": "node src/index.js",
|
||||
"test": "tap --timeout 240 && common-good check --dependency-check-suffix '-i changelog-version -i mkdirp -i nodemon -i stylelint-config-recommended'",
|
||||
"test": "tap --timeout 240 && common-good test --dependency-check-suffix '-i changelog-version -i mkdirp -i nodemon -i stylelint-config-recommended'",
|
||||
"preversion": "npm test",
|
||||
"version": "mv docs/CHANGELOG.md ./ && changelog-version && mv CHANGELOG.md docs/ && git add docs/CHANGELOG.md"
|
||||
},
|
||||
|
|
|
@ -113,7 +113,8 @@ a {
|
|||
color: var(--fg-light);
|
||||
}
|
||||
|
||||
button, .file-button {
|
||||
button,
|
||||
.file-button {
|
||||
cursor: pointer;
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
|
@ -123,19 +124,19 @@ button, .file-button {
|
|||
}
|
||||
|
||||
.file-button {
|
||||
width: 58px;
|
||||
font-size: 8pt;
|
||||
float: right;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
width: 58px;
|
||||
font-size: 8pt;
|
||||
float: right;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
#blob {
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
section.post-preview {
|
||||
|
@ -174,10 +175,9 @@ section > footer > div > form > button {
|
|||
|
||||
section > footer > div > a:hover,
|
||||
section > footer > div > form > button:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
section > footer > div > form > button {
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
|
@ -360,7 +360,7 @@ nav > ul > li > a:hover {
|
|||
}
|
||||
|
||||
nav > ul > li > a.current {
|
||||
font-weight: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
section {
|
||||
|
@ -375,9 +375,9 @@ section {
|
|||
|
||||
.indent section,
|
||||
.thread-container section {
|
||||
margin: unset;
|
||||
border-radius: unset;
|
||||
border-bottom: var(--fg-alt) solid 1px;
|
||||
margin: unset;
|
||||
border-radius: unset;
|
||||
border-bottom: var(--fg-alt) solid 1px;
|
||||
}
|
||||
|
||||
.indent details[open] {
|
||||
|
@ -386,7 +386,7 @@ section {
|
|||
|
||||
.indent section:last-of-type,
|
||||
.thread-container section:last-of-type {
|
||||
border-bottom: unset;
|
||||
border-bottom: unset;
|
||||
}
|
||||
|
||||
.mentions-container {
|
||||
|
@ -453,7 +453,7 @@ section > header {
|
|||
}
|
||||
|
||||
.author-action > a {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
section > header > div {
|
||||
|
@ -522,7 +522,7 @@ section > footer > div > * {
|
|||
}
|
||||
|
||||
section > footer > div > form > button:first-of-type {
|
||||
font-size: 100%;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
section > footer > div > form > button.liked {
|
||||
|
|
181
src/index.js
181
src/index.js
|
@ -146,60 +146,58 @@ const { about, blob, friend, meta, post, vote } = require("./models")({
|
|||
|
||||
// enhance the users' input text by expanding @name to [@name](@feedPub.key)
|
||||
// and slurps up blob uploads and appends a markdown link for it to the text (see handleBlobUpload)
|
||||
const preparePreview = async function(ctx) {
|
||||
const preparePreview = async function (ctx) {
|
||||
let text = String(ctx.request.body.text);
|
||||
|
||||
// find all the @mentions that are not inside a link already
|
||||
// stores name:[matches...]
|
||||
// TODO: sort by relationship
|
||||
// TODO: filter duplicates
|
||||
const mentions = {}
|
||||
|
||||
const mentions = {};
|
||||
|
||||
// This matches for @string followed by a space or other punctuations like ! , or .
|
||||
// The idea here is to match a plain @name but not [@name](...)
|
||||
// also: re.exec is stateful => regex is consumed and thus needs to be re-instantiated for each call
|
||||
const rex = /(?!\[)@([a-zA-Z0-9-]+)([\s\.,!?)~]{1}|$)/g
|
||||
const rex = /(?!\[)@([a-zA-Z0-9-]+)([\s\.,!?)~]{1}|$)/g;
|
||||
// ^ sentence ^
|
||||
// delimiters
|
||||
|
||||
// find @mentions using rex and use about.named() to get the info for them
|
||||
let m
|
||||
let m;
|
||||
while ((m = rex.exec(text)) !== null) {
|
||||
const [match, name] = m;
|
||||
let matches = about.named(name)
|
||||
const name = m[1];
|
||||
let matches = about.named(name);
|
||||
for (const feed of matches) {
|
||||
let found = mentions[name] || []
|
||||
found.push(feed)
|
||||
mentions[name] = found
|
||||
let found = mentions[name] || [];
|
||||
found.push(feed);
|
||||
mentions[name] = found;
|
||||
}
|
||||
}
|
||||
|
||||
// filter the matches depending on the follow relation
|
||||
Object.keys(mentions).forEach(name => {
|
||||
let matches = mentions[name]
|
||||
Object.keys(mentions).forEach((name) => {
|
||||
let matches = mentions[name];
|
||||
// if we find mention matches for a name, and we follow them / they follow us,
|
||||
// then use those matches as suggestions
|
||||
const meaningfulMatches = matches.filter(m => {
|
||||
const meaningfulMatches = matches.filter((m) => {
|
||||
return (m.rel.followsMe || m.rel.following) && m.rel.blocking === false;
|
||||
})
|
||||
});
|
||||
if (meaningfulMatches.length > 0) {
|
||||
matches = meaningfulMatches
|
||||
matches = meaningfulMatches;
|
||||
}
|
||||
mentions[name] = matches
|
||||
})
|
||||
mentions[name] = matches;
|
||||
});
|
||||
|
||||
// replace the text with a markdown link if we have unambiguous match
|
||||
const replacer = (match, name, sign) => {
|
||||
let matches = mentions[name]
|
||||
let matches = mentions[name];
|
||||
if (matches && matches.length === 1) {
|
||||
// we found an exact match, don't send it to frontend as a suggestion
|
||||
delete mentions[name]
|
||||
delete mentions[name];
|
||||
// format markdown link and put the correct sign back at the end
|
||||
return `[@${matches[0].name}](${matches[0].feed})${sign ? sign : ''}`
|
||||
return `[@${matches[0].name}](${matches[0].feed})${sign ? sign : ""}`;
|
||||
}
|
||||
return match
|
||||
}
|
||||
return match;
|
||||
};
|
||||
text = text.replace(rex, replacer);
|
||||
|
||||
// add blob new blob to the end of the document.
|
||||
|
@ -211,10 +209,10 @@ const preparePreview = async function(ctx) {
|
|||
id: ssb.id,
|
||||
name: await about.name(ssb.id),
|
||||
image: await about.image(ssb.id),
|
||||
}
|
||||
};
|
||||
|
||||
return { authorMeta, text , mentions}
|
||||
}
|
||||
return { authorMeta, text, mentions };
|
||||
};
|
||||
|
||||
// handleBlobUpload ingests an uploaded form file.
|
||||
// it takes care of maximum blob size (5meg), exif stripping and mime detection.
|
||||
|
@ -242,15 +240,16 @@ const handleBlobUpload = async function (ctx) {
|
|||
// implementation borrowed from ssb-blob-files
|
||||
// (which operates on a slightly different data structure, sadly)
|
||||
// https://github.com/ssbc/ssb-blob-files/blob/master/async/image-process.js
|
||||
data = Buffer.from(removeExif(dataString), "binary")
|
||||
data = Buffer.from(removeExif(dataString), "binary");
|
||||
|
||||
function removeExif (fileData) {
|
||||
function removeExif(fileData) {
|
||||
const exifOrientation = exif.load(fileData);
|
||||
const orientation = exifOrientation['0th'][exif.ImageIFD.Orientation];
|
||||
const orientation = exifOrientation["0th"][exif.ImageIFD.Orientation];
|
||||
const clean = exif.remove(fileData);
|
||||
if (orientation !== undefined) { // preserve img orientation
|
||||
const exifData = { '0th': {} }
|
||||
exifData['0th'][exif.ImageIFD.Orientation] = orientation;
|
||||
if (orientation !== undefined) {
|
||||
// preserve img orientation
|
||||
const exifData = { "0th": {} };
|
||||
exifData["0th"][exif.ImageIFD.Orientation] = orientation;
|
||||
const exifStr = exif.dump(exifData);
|
||||
return exif.insert(exifStr, clean);
|
||||
} else {
|
||||
|
@ -258,8 +257,10 @@ const handleBlobUpload = async function (ctx) {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
console.warn("blob was likely not a jpeg -- no exif data to remove. proceeding with blob upload");
|
||||
console.warn(e);
|
||||
console.warn(
|
||||
"blob was likely not a jpeg -- no exif data to remove. proceeding with blob upload"
|
||||
);
|
||||
}
|
||||
|
||||
const addBlob = new Promise((resolve, reject) => {
|
||||
|
@ -267,43 +268,42 @@ const handleBlobUpload = async function (ctx) {
|
|||
pull.values([data]),
|
||||
ssb.blobs.add((err, hashedBlobRef) => {
|
||||
if (err) return reject(err);
|
||||
console.log("added", hashedBlobRef)
|
||||
resolve(hashedBlobRef)
|
||||
resolve(hashedBlobRef);
|
||||
})
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
blob = {
|
||||
id: await addBlob,
|
||||
name: blobUpload.name
|
||||
}
|
||||
const FileType = require('file-type');
|
||||
name: blobUpload.name,
|
||||
};
|
||||
const FileType = require("file-type");
|
||||
try {
|
||||
let ftype = await FileType.fromBuffer(data)
|
||||
blob.mime = ftype.mime
|
||||
let ftype = await FileType.fromBuffer(data);
|
||||
blob.mime = ftype.mime;
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
blob.mime = "application/octet-stream"
|
||||
console.warn(error);
|
||||
blob.mime = "application/octet-stream";
|
||||
}
|
||||
}
|
||||
}
|
||||
// append uploaded blob as markdown to the end of the input text
|
||||
if (typeof blob !== "boolean") {
|
||||
if (blob.mime.startsWith("image/")) {
|
||||
text += `\n![${blob.name}](${blob.id})`
|
||||
text += `\n![${blob.name}](${blob.id})`;
|
||||
} else if (blob.mime.startsWith("audio/")) {
|
||||
text += `\n![audio:${blob.name}](${blob.id})`
|
||||
text += `\n![audio:${blob.name}](${blob.id})`;
|
||||
} else if (blob.mime.startsWith("video/")) {
|
||||
text += `\n![video:${blob.name}](${blob.id})`
|
||||
text += `\n![video:${blob.name}](${blob.id})`;
|
||||
} else {
|
||||
text += `\n[${blob.name}](${blob.id})`
|
||||
text += `\n[${blob.name}](${blob.id})`;
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const resolveCommentComponents = async function (ctx) {
|
||||
const { message } = ctx.params;
|
||||
const parentId = message
|
||||
const parentId = message;
|
||||
const parentMessage = await post.get(parentId);
|
||||
const myFeedId = await meta.myFeedId();
|
||||
|
||||
|
@ -316,22 +316,21 @@ const resolveCommentComponents = async function (ctx) {
|
|||
|
||||
const rootMessage = hasRoot
|
||||
? hasFork
|
||||
? parentMessage
|
||||
: await post.get(parentMessage.value.content.root)
|
||||
? parentMessage
|
||||
: await post.get(parentMessage.value.content.root)
|
||||
: parentMessage;
|
||||
|
||||
const messages = await post.topicComments(rootMessage.key);
|
||||
|
||||
messages.push(rootMessage);
|
||||
let contentWarning
|
||||
let contentWarning;
|
||||
if (ctx.request.body) {
|
||||
const rawContentWarning = String(ctx.request.body.contentWarning).trim();
|
||||
contentWarning =
|
||||
rawContentWarning.length > 0 ? rawContentWarning : undefined;
|
||||
}
|
||||
return { messages, myFeedId, parentMessage, contentWarning }
|
||||
}
|
||||
|
||||
return { messages, myFeedId, parentMessage, contentWarning };
|
||||
};
|
||||
|
||||
const {
|
||||
authorView,
|
||||
|
@ -779,24 +778,37 @@ router
|
|||
ctx.body = await publishView();
|
||||
})
|
||||
.get("/comment/:message", async (ctx) => {
|
||||
const { messages, myFeedId, parentMessage } = await resolveCommentComponents(ctx)
|
||||
ctx.body = await commentView({ messages, myFeedId, parentMessage })
|
||||
const {
|
||||
messages,
|
||||
myFeedId,
|
||||
parentMessage,
|
||||
} = await resolveCommentComponents(ctx);
|
||||
ctx.body = await commentView({ messages, myFeedId, parentMessage });
|
||||
})
|
||||
.post("/subtopic/preview/:message", koaBody({ multipart: true }), async (ctx) => {
|
||||
const { message } = ctx.params;
|
||||
const rootMessage = await post.get(message);
|
||||
const myFeedId = await meta.myFeedId();
|
||||
.post(
|
||||
"/subtopic/preview/:message",
|
||||
koaBody({ multipart: true }),
|
||||
async (ctx) => {
|
||||
const { message } = ctx.params;
|
||||
const rootMessage = await post.get(message);
|
||||
const myFeedId = await meta.myFeedId();
|
||||
|
||||
const rawContentWarning = String(ctx.request.body.contentWarning).trim();
|
||||
const contentWarning =
|
||||
rawContentWarning.length > 0 ? rawContentWarning : undefined;
|
||||
const rawContentWarning = String(ctx.request.body.contentWarning).trim();
|
||||
const contentWarning =
|
||||
rawContentWarning.length > 0 ? rawContentWarning : undefined;
|
||||
|
||||
const messages = [rootMessage];
|
||||
const messages = [rootMessage];
|
||||
|
||||
const previewData = await preparePreview(ctx);
|
||||
const previewData = await preparePreview(ctx);
|
||||
|
||||
ctx.body = await previewSubtopicView({ messages, myFeedId, previewData, contentWarning });
|
||||
})
|
||||
ctx.body = await previewSubtopicView({
|
||||
messages,
|
||||
myFeedId,
|
||||
previewData,
|
||||
contentWarning,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post("/subtopic/:message", koaBody(), async (ctx) => {
|
||||
const { message } = ctx.params;
|
||||
const text = String(ctx.request.body.text);
|
||||
|
@ -818,13 +830,28 @@ router
|
|||
ctx.body = await publishSubtopic({ message, text });
|
||||
ctx.redirect(`/thread/${encodeURIComponent(message)}`);
|
||||
})
|
||||
.post("/comment/preview/:message", koaBody({ multipart: true }), async (ctx) => {
|
||||
const { messages, contentWarning, myFeedId, parentMessage } = await resolveCommentComponents(ctx)
|
||||
.post(
|
||||
"/comment/preview/:message",
|
||||
koaBody({ multipart: true }),
|
||||
async (ctx) => {
|
||||
const {
|
||||
messages,
|
||||
contentWarning,
|
||||
myFeedId,
|
||||
parentMessage,
|
||||
} = await resolveCommentComponents(ctx);
|
||||
|
||||
const previewData = await preparePreview(ctx);
|
||||
const previewData = await preparePreview(ctx);
|
||||
|
||||
ctx.body = await previewCommentView({ messages, myFeedId, contentWarning, parentMessage, previewData });
|
||||
})
|
||||
ctx.body = await previewCommentView({
|
||||
messages,
|
||||
myFeedId,
|
||||
contentWarning,
|
||||
parentMessage,
|
||||
previewData,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post("/comment/:message", koaBody(), async (ctx) => {
|
||||
const { message } = ctx.params;
|
||||
const text = String(ctx.request.body.text);
|
||||
|
@ -854,7 +881,7 @@ router
|
|||
rawContentWarning.length > 0 ? rawContentWarning : undefined;
|
||||
|
||||
const previewData = await preparePreview(ctx);
|
||||
ctx.body = await previewView({previewData, contentWarning});
|
||||
ctx.body = await previewView({ previewData, contentWarning });
|
||||
})
|
||||
.post("/publish/", koaBody(), async (ctx) => {
|
||||
const text = String(ctx.request.body.text);
|
||||
|
|
175
src/models.js
175
src/models.js
|
@ -121,114 +121,127 @@ module.exports = ({ cooler, isPublic }) => {
|
|||
// TODO: an alternative would be using ssb.names if available and just loading this as a fallback
|
||||
|
||||
// Two lookup tables to remove old and duplicate names
|
||||
const feeds_to_name = {}
|
||||
let all_the_names = {}
|
||||
const feeds_to_name = {};
|
||||
let all_the_names = {};
|
||||
|
||||
let dirty = false // just stop mindless work (nothing changed) could be smarter thou
|
||||
let running = false // don't run twice
|
||||
let dirty = false; // just stop mindless work (nothing changed) could be smarter thou
|
||||
let running = false; // don't run twice
|
||||
|
||||
// transposeLookupTable flips the lookup around (form feed->name to name->feed)
|
||||
// and also enhances the entries with image and relationship info
|
||||
const transposeLookupTable = () => {
|
||||
if (!dirty) return
|
||||
if (running) return
|
||||
running = true
|
||||
if (!dirty) return;
|
||||
if (running) return;
|
||||
running = true;
|
||||
|
||||
// invalidate old cache
|
||||
// regenerate a new thing because we don't know which entries will be gone
|
||||
all_the_names = {}
|
||||
all_the_names = {};
|
||||
|
||||
const allFeeds = Object.keys(feeds_to_name)
|
||||
console.log(`updating ${allFeeds.length} feeds`)
|
||||
console.time('transpose-name-index')
|
||||
const allFeeds = Object.keys(feeds_to_name);
|
||||
console.log(`updating ${allFeeds.length} feeds`);
|
||||
console.time("transpose-name-index");
|
||||
|
||||
const lookups = []
|
||||
const lookups = [];
|
||||
for (const feed of allFeeds) {
|
||||
const e = feeds_to_name[feed]
|
||||
let pair = { feed, name: e.name }
|
||||
lookups.push(enhanceFeedInfo(pair))
|
||||
const e = feeds_to_name[feed];
|
||||
let pair = { feed, name: e.name };
|
||||
lookups.push(enhanceFeedInfo(pair));
|
||||
}
|
||||
|
||||
// wait for all image and follow lookups
|
||||
Promise.all(lookups).then(() => {
|
||||
dirty = false // all updated
|
||||
running = false
|
||||
console.timeEnd('transpose-name-index')
|
||||
}).catch((err) => {
|
||||
running = false
|
||||
console.warn('lookup transposition failed:', err)
|
||||
})
|
||||
}
|
||||
Promise.all(lookups)
|
||||
.then(() => {
|
||||
dirty = false; // all updated
|
||||
running = false;
|
||||
console.timeEnd("transpose-name-index");
|
||||
})
|
||||
.catch((err) => {
|
||||
running = false;
|
||||
console.warn("lookup transposition failed:", err);
|
||||
});
|
||||
};
|
||||
|
||||
// this function adds the avater image and relationship to the all_the_names lookup table
|
||||
const enhanceFeedInfo = ({feed, name}) => {
|
||||
const enhanceFeedInfo = ({ feed, name }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
getAbout({feedId: feed, key: "image"}).then((img) => {
|
||||
if (img !== null && typeof img !== "string" && typeof img === "object" && typeof img.link === "string") {
|
||||
img = img.link
|
||||
} else if (img === null) {
|
||||
img = nullImage // default empty image if we dont have one
|
||||
}
|
||||
getAbout({ feedId: feed, key: "image" })
|
||||
.then((img) => {
|
||||
if (
|
||||
img !== null &&
|
||||
typeof img !== "string" &&
|
||||
typeof img === "object" &&
|
||||
typeof img.link === "string"
|
||||
) {
|
||||
img = img.link;
|
||||
} else if (img === null) {
|
||||
img = nullImage; // default empty image if we dont have one
|
||||
}
|
||||
|
||||
models.friend.getRelationship(feed).then((rel) => {
|
||||
// append and update lookup table
|
||||
let feeds_named = all_the_names[name] || []
|
||||
feeds_named.push({feed, name, rel, img })
|
||||
all_the_names[name.toLowerCase()] = feeds_named
|
||||
resolve()
|
||||
models.friend
|
||||
.getRelationship(feed)
|
||||
.then((rel) => {
|
||||
// append and update lookup table
|
||||
let feeds_named = all_the_names[name] || [];
|
||||
feeds_named.push({ feed, name, rel, img });
|
||||
all_the_names[name.toLowerCase()] = feeds_named;
|
||||
resolve();
|
||||
|
||||
// TODO: append if these fail!?
|
||||
}).catch(reject)
|
||||
}).catch(reject)
|
||||
})
|
||||
}
|
||||
// TODO: append if these fail!?
|
||||
})
|
||||
.catch(reject);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
cooler.open().then((ssb) => {
|
||||
|
||||
console.time('about-name-warmup') // benchmark the time it takes to stream all existing abouts
|
||||
console.time("about-name-warmup"); // benchmark the time it takes to stream all existing abouts
|
||||
pull(
|
||||
ssb.query.read({
|
||||
live: true, // keep streaming new messages as they arrive
|
||||
query: [
|
||||
{
|
||||
$filter: { // all messages of type:about that have a name field that is typeof string
|
||||
$filter: {
|
||||
// all messages of type:about that have a name field that is typeof string
|
||||
value: {
|
||||
content: {
|
||||
type: "about",
|
||||
name: { $is: "string" }
|
||||
name: { $is: "string" },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
pull.filter((msg) => {
|
||||
// backlog of data is done, only new values from now on
|
||||
if (msg.sync && msg.sync === true) {
|
||||
console.timeEnd('about-name-warmup')
|
||||
transposeLookupTable() // fire once now
|
||||
setInterval(transposeLookupTable, 1000*60) // and then every 60 seconds
|
||||
return false
|
||||
console.timeEnd("about-name-warmup");
|
||||
transposeLookupTable(); // fire once now
|
||||
setInterval(transposeLookupTable, 1000 * 60); // and then every 60 seconds
|
||||
return false;
|
||||
}
|
||||
// only pick messages about self
|
||||
return msg.value.author == msg.value.content.about
|
||||
return msg.value.author == msg.value.content.about;
|
||||
}),
|
||||
pull.drain((msg) => {
|
||||
const name = msg.value.content.name
|
||||
const ts = msg.value.timestamp
|
||||
const feed = msg.value.author
|
||||
const name = msg.value.content.name;
|
||||
const ts = msg.value.timestamp;
|
||||
const feed = msg.value.author;
|
||||
|
||||
const newEntry = { name, ts }
|
||||
const currentEntry = feeds_to_name[feed]
|
||||
if (typeof currentEntry == 'undefined') {
|
||||
dirty = true
|
||||
feeds_to_name[feed] = newEntry
|
||||
} else if (currentEntry.ts < ts) { // overwrite entry if it's newer
|
||||
dirty = true
|
||||
feeds_to_name[feed] = newEntry
|
||||
const newEntry = { name, ts };
|
||||
const currentEntry = feeds_to_name[feed];
|
||||
if (typeof currentEntry == "undefined") {
|
||||
dirty = true;
|
||||
feeds_to_name[feed] = newEntry;
|
||||
} else if (currentEntry.ts < ts) {
|
||||
// overwrite entry if it's newer
|
||||
dirty = true;
|
||||
feeds_to_name[feed] = newEntry;
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
models.about = {
|
||||
|
@ -253,14 +266,14 @@ module.exports = ({ cooler, isPublic }) => {
|
|||
); // First 8 chars of public key
|
||||
},
|
||||
named: (name) => {
|
||||
let found = []
|
||||
let matched = Object.keys(all_the_names).filter(n => {
|
||||
return n.startsWith(name.toLowerCase())
|
||||
})
|
||||
let found = [];
|
||||
let matched = Object.keys(all_the_names).filter((n) => {
|
||||
return n.startsWith(name.toLowerCase());
|
||||
});
|
||||
for (const m of matched) {
|
||||
found = found.concat(all_the_names[m])
|
||||
found = found.concat(all_the_names[m]);
|
||||
}
|
||||
return found
|
||||
return found;
|
||||
},
|
||||
image: async (feedId) => {
|
||||
if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
|
||||
|
@ -321,13 +334,15 @@ module.exports = ({ cooler, isPublic }) => {
|
|||
},
|
||||
want: async ({ blobId }) => {
|
||||
debug("want blob: %s", blobId);
|
||||
cooler.open().then(ssb => {
|
||||
|
||||
// This does not wait for the blob.
|
||||
ssb.blobs.want(blobId);
|
||||
}).catch(err => {
|
||||
console.warn(`failed to want blob:${blobId}: ${err}`)
|
||||
})
|
||||
cooler
|
||||
.open()
|
||||
.then((ssb) => {
|
||||
// This does not wait for the blob.
|
||||
ssb.blobs.want(blobId);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(`failed to want blob:${blobId}: ${err}`);
|
||||
});
|
||||
},
|
||||
search: async ({ query }) => {
|
||||
debug("blob search: %s", query);
|
||||
|
@ -367,7 +382,7 @@ module.exports = ({ cooler, isPublic }) => {
|
|||
following,
|
||||
blocking,
|
||||
};
|
||||
transposeLookupTable() // invalidate @mentions table
|
||||
transposeLookupTable(); // invalidate @mentions table
|
||||
return ssb.publish(content);
|
||||
},
|
||||
follow: (feedId) =>
|
||||
|
|
|
@ -74,8 +74,15 @@ const nbsp = "\xa0";
|
|||
* @param {{href: string, emoji: string, text: string }} input
|
||||
*/
|
||||
const template = (titlePrefix, ...elements) => {
|
||||
const navLink = ({ href, emoji, text }, prefix) =>
|
||||
li(a({ href, class: titlePrefix === text ? "current" : "" }, span({ class: "emoji" }, emoji), nbsp, text));
|
||||
const navLink = ({ href, emoji, text }) =>
|
||||
li(
|
||||
a(
|
||||
{ href, class: titlePrefix === text ? "current" : "" },
|
||||
span({ class: "emoji" }, emoji),
|
||||
nbsp,
|
||||
text
|
||||
)
|
||||
);
|
||||
|
||||
const nodes = html(
|
||||
{ lang: "en" },
|
||||
|
@ -201,9 +208,9 @@ const thread = (messages) => {
|
|||
|
||||
const nextAuthor = lodash.get(nextMsg, "value.meta.author.name");
|
||||
const nextSnippet = postSnippet(
|
||||
lodash.has(nextMsg, "value.content.contentWarning") ?
|
||||
lodash.get(nextMsg, "value.content.contentWarning") :
|
||||
lodash.get(nextMsg, "value.content.text")
|
||||
lodash.has(nextMsg, "value.content.contentWarning")
|
||||
? lodash.get(nextMsg, "value.content.contentWarning")
|
||||
: lodash.get(nextMsg, "value.content.text")
|
||||
);
|
||||
|
||||
msgList.push(summary(`${nextAuthor}: ${nextSnippet}`).outerHTML);
|
||||
|
@ -222,7 +229,10 @@ const thread = (messages) => {
|
|||
}
|
||||
|
||||
const htmlStrings = lodash.flatten(msgList);
|
||||
return div({}, { class: "thread-container", innerHTML: htmlStrings.join("") });
|
||||
return div(
|
||||
{},
|
||||
{ class: "thread-container", innerHTML: htmlStrings.join("") }
|
||||
);
|
||||
};
|
||||
|
||||
const postSnippet = (text) => {
|
||||
|
@ -656,14 +666,34 @@ exports.authorView = ({
|
|||
return template(i18n.profile, prefix, items);
|
||||
};
|
||||
|
||||
exports.previewCommentView = async ({ previewData, messages, myFeedId, parentMessage, contentWarning }) => {
|
||||
exports.previewCommentView = async ({
|
||||
previewData,
|
||||
messages,
|
||||
myFeedId,
|
||||
parentMessage,
|
||||
contentWarning,
|
||||
}) => {
|
||||
const publishAction = `/comment/${encodeURIComponent(messages[0].key)}`;
|
||||
|
||||
const preview = generatePreview({ previewData, contentWarning, action: publishAction })
|
||||
return exports.commentView({ messages, myFeedId, parentMessage }, preview, previewData.text, contentWarning)
|
||||
const preview = generatePreview({
|
||||
previewData,
|
||||
contentWarning,
|
||||
action: publishAction,
|
||||
});
|
||||
return exports.commentView(
|
||||
{ messages, myFeedId, parentMessage },
|
||||
preview,
|
||||
previewData.text,
|
||||
contentWarning
|
||||
);
|
||||
};
|
||||
|
||||
exports.commentView = async ({ messages, myFeedId, parentMessage }, preview, text, contentWarning) => {
|
||||
exports.commentView = async (
|
||||
{ messages, myFeedId, parentMessage },
|
||||
preview,
|
||||
text,
|
||||
contentWarning
|
||||
) => {
|
||||
let markdownMention;
|
||||
|
||||
const messageElements = await Promise.all(
|
||||
|
@ -692,10 +722,8 @@ exports.commentView = async ({ messages, myFeedId, parentMessage }, preview, tex
|
|||
|
||||
return template(
|
||||
i18n.commentTitle({ authorName }),
|
||||
div({ class: "thread-container" },
|
||||
messageElements,
|
||||
),
|
||||
preview !== undefined ? preview : '',
|
||||
div({ class: "thread-container" }, messageElements),
|
||||
preview !== undefined ? preview : "",
|
||||
p(
|
||||
...i18n.commentLabel({ publicOrPrivate, markdownUrl }),
|
||||
...maybeSubtopicText
|
||||
|
@ -708,8 +736,7 @@ exports.commentView = async ({ messages, myFeedId, parentMessage }, preview, tex
|
|||
required: true,
|
||||
name: "text",
|
||||
},
|
||||
text ? text :
|
||||
isPrivate ? null : markdownMention
|
||||
text ? text : isPrivate ? null : markdownMention
|
||||
),
|
||||
label(
|
||||
i18n.contentWarningLabel,
|
||||
|
@ -717,12 +744,12 @@ exports.commentView = async ({ messages, myFeedId, parentMessage }, preview, tex
|
|||
name: "contentWarning",
|
||||
type: "text",
|
||||
class: "contentWarning",
|
||||
value: contentWarning ? contentWarning : '',
|
||||
value: contentWarning ? contentWarning : "",
|
||||
placeholder: i18n.contentWarningPlaceholder,
|
||||
})
|
||||
),
|
||||
button({ type: "submit" }, i18n.preview),
|
||||
label({ class: "file-button", for: "blob"}, i18n.attachFiles),
|
||||
label({ class: "file-button", for: "blob" }, i18n.attachFiles),
|
||||
input({ type: "file", id: "blob", name: "blob" })
|
||||
)
|
||||
);
|
||||
|
@ -812,7 +839,7 @@ exports.publishView = (preview, text, contentWarning) => {
|
|||
},
|
||||
label(
|
||||
i18n.publishLabel({ markdownUrl, linkTarget: "_blank" }),
|
||||
textarea({ required: true, name: "text" }, text ? text : '')
|
||||
textarea({ required: true, name: "text" }, text ? text : "")
|
||||
),
|
||||
label(
|
||||
i18n.contentWarningLabel,
|
||||
|
@ -820,22 +847,22 @@ exports.publishView = (preview, text, contentWarning) => {
|
|||
name: "contentWarning",
|
||||
type: "text",
|
||||
class: "contentWarning",
|
||||
value: contentWarning ? contentWarning : '',
|
||||
value: contentWarning ? contentWarning : "",
|
||||
placeholder: i18n.contentWarningPlaceholder,
|
||||
})
|
||||
),
|
||||
button({ type: "submit" }, i18n.preview),
|
||||
label({ class: "file-button", for: "blob"}, i18n.attachFiles),
|
||||
label({ class: "file-button", for: "blob" }, i18n.attachFiles),
|
||||
input({ type: "file", id: "blob", name: "blob" })
|
||||
)
|
||||
),
|
||||
preview ? preview : '',
|
||||
preview ? preview : "",
|
||||
p(i18n.publishCustomInfo({ href: "/publish/custom" }))
|
||||
);
|
||||
};
|
||||
|
||||
const generatePreview = ({ previewData, contentWarning, action }) => {
|
||||
const { authorMeta, text, mentions } = previewData
|
||||
const { authorMeta, text, mentions } = previewData;
|
||||
|
||||
// craft message that looks like it came from the db
|
||||
// cb: this kinda fragile imo? this is for getting a proper post styling ya?
|
||||
|
@ -845,8 +872,8 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
|
|||
author: authorMeta.id,
|
||||
// sequence: -1,
|
||||
content: {
|
||||
type:"post",
|
||||
text: text
|
||||
type: "post",
|
||||
text: text,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
meta: {
|
||||
|
@ -855,65 +882,82 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
|
|||
author: {
|
||||
name: authorMeta.name,
|
||||
avatar: {
|
||||
url: `/image/64/${encodeURIComponent(authorMeta.image)}`
|
||||
}
|
||||
url: `/image/64/${encodeURIComponent(authorMeta.image)}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
if (contentWarning) msg.value.content.contentWarning = contentWarning
|
||||
},
|
||||
},
|
||||
};
|
||||
if (contentWarning) msg.value.content.contentWarning = contentWarning;
|
||||
const ts = new Date(msg.value.timestamp);
|
||||
lodash.set(msg, "value.meta.timestamp.received.iso8601", ts.toISOString());
|
||||
const ago = Date.now() - Number(ts);
|
||||
const prettyAgo = prettyMs(ago, { compact: true });
|
||||
lodash.set(msg, "value.meta.timestamp.received.since", prettyAgo);
|
||||
return div(
|
||||
Object.keys(mentions).length === 0 ? "" :
|
||||
section({ class: "mention-suggestions"},
|
||||
h2(i18n.mentionsMatching),
|
||||
Object.keys(mentions).map((name) => {
|
||||
let matches = mentions[name]
|
||||
Object.keys(mentions).length === 0
|
||||
? ""
|
||||
: section(
|
||||
{ class: "mention-suggestions" },
|
||||
h2(i18n.mentionsMatching),
|
||||
Object.keys(mentions).map((name) => {
|
||||
let matches = mentions[name];
|
||||
|
||||
return div(
|
||||
matches.map(m => {
|
||||
let relationship = { emoji: "", desc: "" }
|
||||
if (m.rel.followsMe && m.rel.following) {
|
||||
// mutuals get the handshake emoji
|
||||
relationship.emoji = "🤝"
|
||||
relationship.desc = i18n.relationshipMutuals
|
||||
} else if (m.rel.following) {
|
||||
// if we're following that's an eyes emoji
|
||||
relationship.emoji = "👀"
|
||||
relationship.desc = i18n.relationshipFollowing
|
||||
} else if (m.rel.followsMe) {
|
||||
// follower has waving-hand emoji
|
||||
relationship.emoji = "👋"
|
||||
relationship.desc = i18n.relationshipTheyFollow
|
||||
} else {
|
||||
// no relationship has question mark emoji
|
||||
relationship.emoji = "❓"
|
||||
relationship.desc = i18n.relationshipNotFollowing
|
||||
}
|
||||
return div({ class: "mentions-container" },
|
||||
a({ class: "mentions-image",
|
||||
href: `/author/${encodeURIComponent(m.feed)}` },
|
||||
img({ src: `/image/64/${encodeURIComponent(m.img)}`})
|
||||
),
|
||||
a(
|
||||
{ class: "mentions-name", href: `/author/${encodeURIComponent(m.feed)}` },
|
||||
m.name
|
||||
),
|
||||
div({ class: "emo-rel" },
|
||||
span({ class: "emoji", title: relationship.desc }, relationship.emoji),
|
||||
span({ class: "mentions-listing" }, `[@${m.name}](${m.feed})`)
|
||||
)
|
||||
)
|
||||
return div(
|
||||
matches.map((m) => {
|
||||
let relationship = { emoji: "", desc: "" };
|
||||
if (m.rel.followsMe && m.rel.following) {
|
||||
// mutuals get the handshake emoji
|
||||
relationship.emoji = "🤝";
|
||||
relationship.desc = i18n.relationshipMutuals;
|
||||
} else if (m.rel.following) {
|
||||
// if we're following that's an eyes emoji
|
||||
relationship.emoji = "👀";
|
||||
relationship.desc = i18n.relationshipFollowing;
|
||||
} else if (m.rel.followsMe) {
|
||||
// follower has waving-hand emoji
|
||||
relationship.emoji = "👋";
|
||||
relationship.desc = i18n.relationshipTheyFollow;
|
||||
} else {
|
||||
// no relationship has question mark emoji
|
||||
relationship.emoji = "❓";
|
||||
relationship.desc = i18n.relationshipNotFollowing;
|
||||
}
|
||||
return div(
|
||||
{ class: "mentions-container" },
|
||||
a(
|
||||
{
|
||||
class: "mentions-image",
|
||||
href: `/author/${encodeURIComponent(m.feed)}`,
|
||||
},
|
||||
img({ src: `/image/64/${encodeURIComponent(m.img)}` })
|
||||
),
|
||||
a(
|
||||
{
|
||||
class: "mentions-name",
|
||||
href: `/author/${encodeURIComponent(m.feed)}`,
|
||||
},
|
||||
m.name
|
||||
),
|
||||
div(
|
||||
{ class: "emo-rel" },
|
||||
span(
|
||||
{ class: "emoji", title: relationship.desc },
|
||||
relationship.emoji
|
||||
),
|
||||
span(
|
||||
{ class: "mentions-listing" },
|
||||
`[@${m.name}](${m.feed})`
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
})
|
||||
),
|
||||
section({ class: "post-preview" },
|
||||
post({msg}),
|
||||
),
|
||||
section(
|
||||
{ class: "post-preview" },
|
||||
post({ msg }),
|
||||
|
||||
// doesn't need blobs, preview adds them to the text
|
||||
form(
|
||||
|
@ -924,22 +968,26 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
|
|||
value: contentWarning,
|
||||
}),
|
||||
input({
|
||||
name: "text",
|
||||
type: "hidden",
|
||||
value: text,
|
||||
name: "text",
|
||||
type: "hidden",
|
||||
value: text,
|
||||
}),
|
||||
button({ type: "submit" }, i18n.publish),
|
||||
),
|
||||
button({ type: "submit" }, i18n.publish)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
exports.previewView = ({ previewData, contentWarning }) => {
|
||||
const publishAction = "/publish";
|
||||
|
||||
const preview = generatePreview({ previewData, contentWarning, action: publishAction })
|
||||
return exports.publishView(preview, previewData.text, contentWarning)
|
||||
}
|
||||
const preview = generatePreview({
|
||||
previewData,
|
||||
contentWarning,
|
||||
action: publishAction,
|
||||
});
|
||||
return exports.publishView(preview, previewData.text, contentWarning);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{status: object, peers: any[], theme: string, themeNames: string[], version: string }} input
|
||||
|
@ -1173,14 +1221,33 @@ exports.threadsView = ({ messages }) => {
|
|||
});
|
||||
};
|
||||
|
||||
exports.previewSubtopicView = async ({ previewData, messages, myFeedId, contentWarning }) => {
|
||||
exports.previewSubtopicView = async ({
|
||||
previewData,
|
||||
messages,
|
||||
myFeedId,
|
||||
contentWarning,
|
||||
}) => {
|
||||
const publishAction = `/subtopic/${encodeURIComponent(messages[0].key)}`;
|
||||
|
||||
const preview = generatePreview({ previewData, contentWarning, action: publishAction })
|
||||
return exports.subtopicView({ messages, myFeedId }, preview, previewData.text, contentWarning)
|
||||
const preview = generatePreview({
|
||||
previewData,
|
||||
contentWarning,
|
||||
action: publishAction,
|
||||
});
|
||||
return exports.subtopicView(
|
||||
{ messages, myFeedId },
|
||||
preview,
|
||||
previewData.text,
|
||||
contentWarning
|
||||
);
|
||||
};
|
||||
|
||||
exports.subtopicView = async ({ messages, myFeedId }, preview, text, contentWarning) => {
|
||||
exports.subtopicView = async (
|
||||
{ messages, myFeedId },
|
||||
preview,
|
||||
text,
|
||||
contentWarning
|
||||
) => {
|
||||
const subtopicForm = `/subtopic/preview/${encodeURIComponent(
|
||||
messages[messages.length - 1].key
|
||||
)}`;
|
||||
|
@ -1206,10 +1273,8 @@ exports.subtopicView = async ({ messages, myFeedId }, preview, text, contentWarn
|
|||
|
||||
return template(
|
||||
i18n.subtopicTitle({ authorName }),
|
||||
div({ class: "thread-container" },
|
||||
messageElements,
|
||||
),
|
||||
preview !== undefined ? preview : '',
|
||||
div({ class: "thread-container" }, messageElements),
|
||||
preview !== undefined ? preview : "",
|
||||
p(i18n.subtopicLabel({ markdownUrl })),
|
||||
form(
|
||||
{ action: subtopicForm, method: "post", enctype: "multipart/form-data" },
|
||||
|
@ -1227,12 +1292,12 @@ exports.subtopicView = async ({ messages, myFeedId }, preview, text, contentWarn
|
|||
name: "contentWarning",
|
||||
type: "text",
|
||||
class: "contentWarning",
|
||||
value: contentWarning ? contentWarning : '',
|
||||
value: contentWarning ? contentWarning : "",
|
||||
placeholder: i18n.contentWarningPlaceholder,
|
||||
})
|
||||
),
|
||||
button({ type: "submit" }, i18n.preview),
|
||||
label({ class: "file-button", for: "blob"}, i18n.attachFiles),
|
||||
label({ class: "file-button", for: "blob" }, i18n.attachFiles),
|
||||
input({ type: "file", id: "blob", name: "blob" })
|
||||
)
|
||||
);
|
||||
|
|
|
@ -31,38 +31,41 @@ const paths = [
|
|||
tap.setTimeout(0);
|
||||
|
||||
tap.test("DNS rebind attack fails", (t) => {
|
||||
t.plan(1);
|
||||
supertest(app)
|
||||
.get("/inbox")
|
||||
.set("Host", "example.com")
|
||||
.expect(400)
|
||||
.end(t.error);
|
||||
.end((err) => {
|
||||
t.equal(err, null);
|
||||
t.end();
|
||||
});
|
||||
});
|
||||
|
||||
tap.test("CSRF attack should fail with no referer", (t) => {
|
||||
t.plan(1);
|
||||
supertest(app).post("/conn/settings/stop").expect(400).end(t.error);
|
||||
supertest(app).post("/conn/settings/stop").expect(400).end(t.end);
|
||||
});
|
||||
|
||||
tap.test("CSRF attack should fail with wrong referer", (t) => {
|
||||
t.plan(1);
|
||||
supertest(app)
|
||||
.post("/conn/settings/stop")
|
||||
.set("Host", "example.com")
|
||||
.expect(400)
|
||||
.end(t.error);
|
||||
.end((err) => {
|
||||
t.equal(err, null);
|
||||
t.end();
|
||||
});
|
||||
});
|
||||
|
||||
paths.forEach((path) => {
|
||||
tap.test(path, (t) => {
|
||||
t.plan(1);
|
||||
supertest(app)
|
||||
.get(path)
|
||||
.set("Host", "localhost")
|
||||
.expect(200)
|
||||
.end((err) => {
|
||||
console.log(path);
|
||||
t.error(err);
|
||||
t.equal(err, null);
|
||||
console.log("done:" + path);
|
||||
t.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue