Merge branch 'master' of github.com:fraction/oasis into add-i18n
This commit is contained in:
commit
1099395dfa
|
@ -14,6 +14,7 @@
|
|||
"LGPL",
|
||||
"Machinekit",
|
||||
"manyverse",
|
||||
"minlength",
|
||||
"monokai",
|
||||
"msgs",
|
||||
"multiserver",
|
||||
|
@ -31,10 +32,10 @@
|
|||
"roadmap",
|
||||
"sameorigin",
|
||||
"shortname",
|
||||
"systemctl",
|
||||
"socio",
|
||||
"ssbc",
|
||||
"summerfruit",
|
||||
"systemctl",
|
||||
"systemd",
|
||||
"unfollow",
|
||||
"unikitty",
|
||||
|
|
10
README.md
10
README.md
|
@ -26,7 +26,7 @@ Options:
|
|||
|
||||
## Installation
|
||||
|
||||
Most people should install Oasis with [npm](https://npmjs.org/).
|
||||
Most people should install stable releases with [npm](https://npmjs.org/).
|
||||
|
||||
```shell
|
||||
npm --global install @fraction/oasis@latest
|
||||
|
@ -34,13 +34,19 @@ npm --global install @fraction/oasis@latest
|
|||
|
||||
Please make sure that your Node.js version is the [**current** or **active LTS** release](https://nodejs.org/en/about/releases/).
|
||||
|
||||
For faster updates and less stability, install from GitHub and upgrade often.
|
||||
|
||||
```shell
|
||||
npm --global install github:fraction/oasis
|
||||
```
|
||||
|
||||
Want more? Check out [`install.md`](https://github.com/fraction/oasis/blob/master/docs/install.md).
|
||||
|
||||
## Resources
|
||||
|
||||
- [Contributing](https://github.com/fraction/oasis/blob/master/docs/contributing.md)
|
||||
- [Architecture](https://github.com/fraction/oasis/blob/master/docs/architecture.md)
|
||||
- [Help](https://github.com/fraction/oasis/issues/new/choose)
|
||||
- [Help](https://github.com/fraction/oasis/issues/new)
|
||||
- [Roadmap](https://github.com/fraction/oasis/blob/master/docs/roadmap.md)
|
||||
- [Security Policy](https://github.com/fraction/oasis/blob/master/docs/security.md)
|
||||
- [Source Code](https://github.com/fraction/oasis.git)
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
# Blob security
|
||||
|
||||
**This is how we secure blob pages from interacting with Oasis. If you notice
|
||||
any errors or omissions, please follow the steps in the security policy.**
|
||||
|
||||
One of the problems we have when hosting content from other people in a P2P
|
||||
network is avoiding
|
||||
[arbitrary code execution](https://en.wikipedia.org/wiki/Arbitrary_code_execution).
|
||||
In the context of Oasis, we need to be very sure that we aren't letting any code
|
||||
other than Oasis run in the browser. Markdown is a security concern, but it's
|
||||
got lots of eyeballs on the problem, whereas blob security is a security
|
||||
concern without any common best practices. The problem we need to solve isn't
|
||||
super common: hosting arbitrary data, especially HTML, in a safe way that doesn't
|
||||
open security vulnerabilities.
|
||||
|
||||
The way we currently deal with this is a [content security policy (CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP),
|
||||
which gives Oasis a way to tell the web browser which features can be safely
|
||||
disabled. Since Oasis doesn't use any front-end JavaScript, we can disable all
|
||||
JavaScript being run by the web browser. This is _huge_ and massively reduces
|
||||
the surface area that might be vulnerable to attack. You can find all of the
|
||||
CSP code in [`http.js`](https://github.com/fraction/oasis/blob/master/src/http.js).
|
||||
|
||||
With JavaScript out of the way, the only attack vector that we should worry
|
||||
about is an [HTML form](https://developer.mozilla.org/en-US/docs/Learn/Forms#See_also).
|
||||
If one of these were embedded in a blob, they would be able to send HTTP POST
|
||||
requests to our API endpoints, impersonating the user. A button called "click
|
||||
me", could publish posts, change follow status, make changes to our settings
|
||||
page, or other bad behavior that we want to avoid.
|
||||
|
||||
The mitigation for this is a referrer check on all POST endpoints, which helps
|
||||
us guarantee that all form submissions came from a non-blob page. If we receive
|
||||
an HTTP POST without a referrer, we throw an error. If we receive a referrer from
|
||||
a blob page, we throw an error. If a form submission passes these two checks,
|
||||
we can safely assume that the POST request came from a legitimate person using
|
||||
Oasis.
|
|
@ -393,7 +393,7 @@ label {
|
|||
nav > ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-around;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ module.exports = ({ host, port, middleware }) => {
|
|||
// Output full error objects
|
||||
err.message = err.stack;
|
||||
err.expose = true;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
|
|
@ -270,11 +270,15 @@ router
|
|||
})
|
||||
.get("/image/:imageSize/:blobId", async ctx => {
|
||||
const { blobId, imageSize } = ctx.params;
|
||||
ctx.type = "image/png";
|
||||
if (sharp) {
|
||||
ctx.type = "image/png";
|
||||
}
|
||||
|
||||
const fakePixel = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64"
|
||||
);
|
||||
|
||||
const fakeImage = imageSize =>
|
||||
sharp
|
||||
? sharp({
|
||||
|
@ -290,7 +294,10 @@ router
|
|||
}
|
||||
}
|
||||
})
|
||||
.png()
|
||||
.toBuffer()
|
||||
: new Promise(resolve => resolve(fakePixel));
|
||||
|
||||
const image = async ({ blobId, imageSize }) => {
|
||||
const bufferSource = await blob.get({ blobId });
|
||||
const fakeId = "&0000000000000000000000000000000000000000000=.sha256";
|
||||
|
|
358
src/models.js
358
src/models.js
|
@ -643,6 +643,15 @@ module.exports = cooler => {
|
|||
},
|
||||
latestFollowing: async () => {
|
||||
const ssb = await cooler.connect();
|
||||
const { id } = ssb;
|
||||
|
||||
const relationshipObject = await cooler.get(ssb.friends.get, {
|
||||
source: id
|
||||
});
|
||||
|
||||
const followingList = Object.entries(relationshipObject)
|
||||
.filter(([, val]) => val === true)
|
||||
.map(([key]) => key);
|
||||
|
||||
const myFeedId = ssb.id;
|
||||
|
||||
|
@ -656,14 +665,11 @@ module.exports = cooler => {
|
|||
const messages = await new Promise((resolve, reject) => {
|
||||
pull(
|
||||
source,
|
||||
pull.asyncMap((message, cb) => {
|
||||
models.friend.isFollowing(message.value.author).then(following => {
|
||||
cb(null, following ? message : null);
|
||||
});
|
||||
}),
|
||||
pull.filter(
|
||||
message =>
|
||||
message !== null && typeof message.value.content !== "string"
|
||||
followingList.includes(message.value.author) &&
|
||||
message !== null &&
|
||||
typeof message.value.content !== "string"
|
||||
),
|
||||
pull.take(maxMessages),
|
||||
pull.collect((err, collectedMessages) => {
|
||||
|
@ -805,187 +811,195 @@ module.exports = cooler => {
|
|||
const myFeedId = ssb.id;
|
||||
|
||||
const options = configure({ id: msgId }, customOptions);
|
||||
const rawMsg = await cooler.get(ssb.get, options);
|
||||
return cooler
|
||||
.get(ssb.get, options)
|
||||
.then(async rawMsg => {
|
||||
debug("got raw message");
|
||||
|
||||
debug("got raw message");
|
||||
const parents = [];
|
||||
|
||||
const parents = [];
|
||||
const getRootAncestor = msg =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (msg.key == null) {
|
||||
debug("something is very wrong, we used `{ meta: true }`");
|
||||
resolve(parents);
|
||||
} else {
|
||||
debug("getting root ancestor of %s", msg.key);
|
||||
|
||||
const getRootAncestor = msg =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (msg.key == null) {
|
||||
debug("something is very wrong, we used `{ meta: true }`");
|
||||
resolve(parents);
|
||||
} else {
|
||||
debug("getting root ancestor of %s", msg.key);
|
||||
if (typeof msg.value.content === "string") {
|
||||
debug("private message");
|
||||
// Private message we can't decrypt, stop looking for parents.
|
||||
resolve(parents);
|
||||
}
|
||||
|
||||
if (typeof msg.value.content === "string") {
|
||||
debug("private message");
|
||||
// Private message we can't decrypt, stop looking for parents.
|
||||
resolve(parents);
|
||||
}
|
||||
if (msg.value.content.type !== "post") {
|
||||
debug("not a post");
|
||||
resolve(msg);
|
||||
}
|
||||
|
||||
if (msg.value.content.type !== "post") {
|
||||
debug("not a post");
|
||||
resolve(msg);
|
||||
}
|
||||
|
||||
if (isLooseReply(msg)) {
|
||||
debug("reply, get the parent");
|
||||
try {
|
||||
// It's a message reply, get the parent!
|
||||
cooler
|
||||
.get(ssb.get, {
|
||||
id: msg.value.content.fork,
|
||||
meta: true,
|
||||
private: true
|
||||
})
|
||||
.then(fork => {
|
||||
resolve(getRootAncestor(fork));
|
||||
})
|
||||
.catch(reject);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
resolve(msg);
|
||||
if (isLooseReply(msg)) {
|
||||
debug("reply, get the parent");
|
||||
try {
|
||||
// It's a message reply, get the parent!
|
||||
cooler
|
||||
.get(ssb.get, {
|
||||
id: msg.value.content.fork,
|
||||
meta: true,
|
||||
private: true
|
||||
})
|
||||
.then(fork => {
|
||||
resolve(getRootAncestor(fork));
|
||||
})
|
||||
.catch(reject);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
resolve(msg);
|
||||
}
|
||||
} else if (isLooseComment(msg)) {
|
||||
debug("comment: %s", msg.value.content.root);
|
||||
try {
|
||||
// It's a thread reply, get the parent!
|
||||
cooler
|
||||
.get(ssb.get, {
|
||||
id: msg.value.content.root,
|
||||
meta: true,
|
||||
private: true
|
||||
})
|
||||
.then(root => {
|
||||
resolve(getRootAncestor(root));
|
||||
})
|
||||
.catch(reject);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
resolve(msg);
|
||||
}
|
||||
} else if (isLooseRoot(msg)) {
|
||||
debug("got root ancestor");
|
||||
resolve(msg);
|
||||
} else {
|
||||
// type !== "post", probably
|
||||
// this should show up as JSON
|
||||
debug(
|
||||
"got mysterious root ancestor that fails all known schemas"
|
||||
);
|
||||
debug("%O", msg);
|
||||
resolve(msg);
|
||||
}
|
||||
}
|
||||
} else if (isLooseComment(msg)) {
|
||||
debug("comment: %s", msg.value.content.root);
|
||||
try {
|
||||
// It's a thread reply, get the parent!
|
||||
cooler
|
||||
.get(ssb.get, {
|
||||
id: msg.value.content.root,
|
||||
meta: true,
|
||||
private: true
|
||||
})
|
||||
.then(root => {
|
||||
resolve(getRootAncestor(root));
|
||||
})
|
||||
.catch(reject);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
resolve(msg);
|
||||
}
|
||||
} else if (isLooseRoot(msg)) {
|
||||
debug("got root ancestor");
|
||||
resolve(msg);
|
||||
} else {
|
||||
// type !== "post", probably
|
||||
// this should show up as JSON
|
||||
debug(
|
||||
"got mysterious root ancestor that fails all known schemas"
|
||||
);
|
||||
debug("%O", msg);
|
||||
resolve(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const getReplies = key =>
|
||||
new Promise((resolve, reject) => {
|
||||
const filterQuery = {
|
||||
$filter: {
|
||||
dest: key
|
||||
}
|
||||
};
|
||||
const getReplies = key =>
|
||||
new Promise((resolve, reject) => {
|
||||
const filterQuery = {
|
||||
$filter: {
|
||||
dest: key
|
||||
}
|
||||
};
|
||||
|
||||
cooler
|
||||
.read(ssb.backlinks.read, {
|
||||
query: [filterQuery],
|
||||
index: "DTA" // use asserted timestamps
|
||||
})
|
||||
.then(referenceStream => {
|
||||
pull(
|
||||
referenceStream,
|
||||
pull.filter(msg => {
|
||||
const isPost =
|
||||
lodash.get(msg, "value.content.type") === "post";
|
||||
if (isPost === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const root = lodash.get(msg, "value.content.root");
|
||||
const fork = lodash.get(msg, "value.content.fork");
|
||||
|
||||
if (root !== key && fork !== key) {
|
||||
// mention
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fork === key) {
|
||||
// not a reply to this post
|
||||
// it's a reply *to a reply* of this post
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
pull.collect((err, messages) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(messages || undefined);
|
||||
}
|
||||
cooler
|
||||
.read(ssb.backlinks.read, {
|
||||
query: [filterQuery],
|
||||
index: "DTA" // use asserted timestamps
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
.then(referenceStream => {
|
||||
pull(
|
||||
referenceStream,
|
||||
pull.filter(msg => {
|
||||
const isPost =
|
||||
lodash.get(msg, "value.content.type") === "post";
|
||||
if (isPost === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat
|
||||
const flattenDeep = arr1 =>
|
||||
arr1.reduce(
|
||||
(acc, val) =>
|
||||
Array.isArray(val) ? acc.concat(flattenDeep(val)) : acc.concat(val),
|
||||
[]
|
||||
);
|
||||
const root = lodash.get(msg, "value.content.root");
|
||||
const fork = lodash.get(msg, "value.content.fork");
|
||||
|
||||
const getDeepReplies = key =>
|
||||
new Promise((resolve, reject) => {
|
||||
const oneDeeper = async (replyKey, depth) => {
|
||||
const replies = await getReplies(replyKey);
|
||||
debug(
|
||||
"replies",
|
||||
replies.map(m => m.key)
|
||||
if (root !== key && fork !== key) {
|
||||
// mention
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fork === key) {
|
||||
// not a reply to this post
|
||||
// it's a reply *to a reply* of this post
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
pull.collect((err, messages) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(messages || undefined);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat
|
||||
const flattenDeep = arr1 =>
|
||||
arr1.reduce(
|
||||
(acc, val) =>
|
||||
Array.isArray(val)
|
||||
? acc.concat(flattenDeep(val))
|
||||
: acc.concat(val),
|
||||
[]
|
||||
);
|
||||
|
||||
debug("found %s replies for %s", replies.length, replyKey);
|
||||
const getDeepReplies = key =>
|
||||
new Promise((resolve, reject) => {
|
||||
const oneDeeper = async (replyKey, depth) => {
|
||||
const replies = await getReplies(replyKey);
|
||||
debug(
|
||||
"replies",
|
||||
replies.map(m => m.key)
|
||||
);
|
||||
|
||||
if (replies.length === 0) {
|
||||
return replies;
|
||||
}
|
||||
return Promise.all(
|
||||
replies.map(async reply => {
|
||||
const deeperReplies = await oneDeeper(reply.key, depth + 1);
|
||||
lodash.set(reply, "value.meta.thread.depth", depth);
|
||||
lodash.set(reply, "value.meta.thread.reply", true);
|
||||
return [reply, deeperReplies];
|
||||
})
|
||||
);
|
||||
};
|
||||
oneDeeper(key, 0)
|
||||
.then(nested => {
|
||||
const nestedReplies = [...nested];
|
||||
const deepReplies = flattenDeep(nestedReplies);
|
||||
resolve(deepReplies);
|
||||
})
|
||||
.catch(reject);
|
||||
debug("found %s replies for %s", replies.length, replyKey);
|
||||
|
||||
if (replies.length === 0) {
|
||||
return replies;
|
||||
}
|
||||
return Promise.all(
|
||||
replies.map(async reply => {
|
||||
const deeperReplies = await oneDeeper(reply.key, depth + 1);
|
||||
lodash.set(reply, "value.meta.thread.depth", depth);
|
||||
lodash.set(reply, "value.meta.thread.reply", true);
|
||||
return [reply, deeperReplies];
|
||||
})
|
||||
);
|
||||
};
|
||||
oneDeeper(key, 0)
|
||||
.then(nested => {
|
||||
const nestedReplies = [...nested];
|
||||
const deepReplies = flattenDeep(nestedReplies);
|
||||
resolve(deepReplies);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
debug("about to get root ancestor");
|
||||
const rootAncestor = await getRootAncestor(rawMsg);
|
||||
debug("got root ancestors");
|
||||
const deepReplies = await getDeepReplies(rootAncestor.key);
|
||||
debug("got deep replies");
|
||||
|
||||
const allMessages = [rootAncestor, ...deepReplies].map(message => {
|
||||
const isThreadTarget = message.key === msgId;
|
||||
lodash.set(message, "value.meta.thread.target", isThreadTarget);
|
||||
return message;
|
||||
});
|
||||
|
||||
return await transform(ssb, allMessages, myFeedId);
|
||||
})
|
||||
.catch(() => {
|
||||
throw new Error(
|
||||
"Message not found in the database. You've done nothing wrong. Maybe try again later?"
|
||||
);
|
||||
});
|
||||
|
||||
debug("about to get root ancestor");
|
||||
const rootAncestor = await getRootAncestor(rawMsg);
|
||||
debug("got root ancestors");
|
||||
const deepReplies = await getDeepReplies(rootAncestor.key);
|
||||
debug("got deep replies");
|
||||
|
||||
const allMessages = [rootAncestor, ...deepReplies].map(message => {
|
||||
const isThreadTarget = message.key === msgId;
|
||||
lodash.set(message, "value.meta.thread.target", isThreadTarget);
|
||||
return message;
|
||||
});
|
||||
|
||||
const transformed = await transform(ssb, allMessages, myFeedId);
|
||||
return transformed;
|
||||
},
|
||||
get: async (msgId, customOptions) => {
|
||||
debug("get: %s", msgId);
|
||||
|
|
|
@ -580,14 +580,28 @@ exports.replyView = async ({ messages, myFeedId }) => {
|
|||
);
|
||||
};
|
||||
|
||||
exports.searchView = ({ messages, query }) =>
|
||||
template(
|
||||
exports.searchView = ({ messages, query }) => {
|
||||
const searchInput = input({
|
||||
name: "query",
|
||||
required: false,
|
||||
type: "search",
|
||||
value: query
|
||||
});
|
||||
|
||||
// - Minimum length of 3 because otherwise SSB-Search hangs forever. :)
|
||||
// https://github.com/ssbc/ssb-search/issues/8
|
||||
// - Using `setAttribute()` because HyperScript (the HyperAxe dependency has
|
||||
// a bug where the `minlength` property is being ignored. No idea why.
|
||||
// https://github.com/hyperhype/hyperscript/issues/91
|
||||
searchInput.setAttribute("minlength", 3);
|
||||
|
||||
return template(
|
||||
section(
|
||||
form(
|
||||
{ action: "/search", method: "get" },
|
||||
header(strong(i18n.search)),
|
||||
label({ for: "query" }, i18n.searchLabel),
|
||||
input({ required: true, type: "search", name: "query", value: query }),
|
||||
searchInput,
|
||||
button(
|
||||
{
|
||||
type: "submit"
|
||||
|
@ -598,3 +612,4 @@ exports.searchView = ({ messages, query }) =>
|
|||
),
|
||||
messages.map(msg => post({ msg }))
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue