diff --git a/web/.env.sample b/web/.env.sample new file mode 100644 index 0000000..496007c --- /dev/null +++ b/web/.env.sample @@ -0,0 +1,5 @@ +PORT=3000 +SSB_KEY= +COOKIES_SECRET= +SENTRY_DSN= +SENDGRID_API_KEY= \ No newline at end of file diff --git a/web/index.js b/web/index.js index 5f2b07a..ee057ff 100644 --- a/web/index.js +++ b/web/index.js @@ -1,4 +1,3 @@ - let server; require("./lib/ssb"); @@ -6,8 +5,8 @@ setTimeout(() => { server = require("./lib/express"); }, 500); -let mode = process.env.MODE || "client"; -if (mode == "client") { +let mode = process.env.MODE || "standalone"; +if (mode == "standalone") { setTimeout(() => { require("./lib/electron"); }, 1000); diff --git a/web/lib/express.js b/web/lib/express.js index 9cd862b..7a4553e 100644 --- a/web/lib/express.js +++ b/web/lib/express.js @@ -8,6 +8,7 @@ const { reconstructKeys, uploadPicture, isPhone, + ssbFolder, } = require("./utils"); const queries = require("./queries"); const serveBlobs = require("./serve-blobs"); @@ -22,10 +23,12 @@ const cookieEncrypter = require("cookie-encrypter"); const expressLayouts = require("express-ejs-layouts"); const mobileRoutes = require("./mobile-routes"); const ejsUtils = require("ejs/lib/utils"); +const fs = require("fs"); +const ssbKeys = require("ssb-keys"); -let mode = process.env.MODE || "client"; +const mode = process.env.MODE || "standalone"; -let profileUrl = (id, path = "") => { +const profileUrl = (id, path = "") => { return `/profile/${id}${path}`; }; @@ -43,16 +46,18 @@ app.set("view engine", "ejs"); app.set("views", `${__dirname}/../views`); app.use(express.static(`${__dirname}/../public`)); app.use(fileUpload()); -const cookieSecret = - process.env.COOKIES_SECRET || "set_cookie_secret_you_are_unsafe"; // has to be 32-bits + const cookieOptions = { httpOnly: true, signed: true, expires: new Date(253402300000000), // Friday, 31 Dec 9999 23:59:59 GMT, nice date from stackoverflow sameSite: "Lax", }; -app.use(cookieParser(cookieSecret)); -if (mode != "client") { +if (mode != "standalone") { + const cookieSecret = + process.env.COOKIES_SECRET || "set_cookie_secret_you_are_unsafe"; // has to be 32-bits + + app.use(cookieParser(cookieSecret)); app.use(cookieEncrypter(cookieSecret)); } app.use(expressLayouts); @@ -71,28 +76,32 @@ app.use(async (req, res, next) => { syncing: ssb.isSyncing(), }; res.locals.context = req.context; + + let key; try { - const key = req.signedCookies["ssb_key"]; - if (!key) return next(); + if (mode == "standalone") { + const isLoggedOut = fs.existsSync(`${ssbFolder()}/logged-out`); - const parsedKey = JSON.parse(key); - if (!parsedKey.id) return next(); + key = !isLoggedOut && ssbKeys.loadSync(`${ssbFolder()}/secret`); + } else { + key = req.signedCookies["ssb_key"]; + if (key) key = JSON.parse(key); + } + } catch (_) {} + if (!key || !key.id) return next(); - ssb.client().identities.addUnboxer(parsedKey); - req.context.profile = await queries.getProfile(parsedKey.id); - req.context.profile.key = parsedKey; + ssb.client().identities.addUnboxer(key); + req.context.profile = (await queries.getProfile(key.id)) || {}; + req.context.profile.key = key; - const isRootUser = - req.context.profile.id == ssb.client().id || - process.env.NODE_ENV != "production"; + const isRootUser = + req.context.profile.id == ssb.client().id || + process.env.NODE_ENV != "production"; - req.context.profile.debug = isRootUser; - req.context.profile.admin = isRootUser || mode == "client"; + req.context.profile.debug = isRootUser; + req.context.profile.admin = isRootUser || mode == "standalone"; - next(); - } catch (e) { - next(e); - } + next(); }); app.use((_req, res, next) => { res.locals.profileUrl = profileUrl; @@ -174,20 +183,30 @@ router.get( ); const doLogin = async (submittedKey, res) => { + let decodedKey; try { - const decodedKey = reconstructKeys(submittedKey); - res.cookie("ssb_key", JSON.stringify(decodedKey), cookieOptions); - - decodedKey.private = "[removed]"; - debug("Login with key", decodedKey); - - await queries.autofollow(decodedKey.id); - - res.redirect("/"); + decodedKey = reconstructKeys(submittedKey); } catch (e) { debug("Error on login", e); - res.send("Invalid key"); + return res.send("Invalid key"); } + + if (mode == "standalone") { + fs.unlinkSync(`${ssbFolder()}/secret`); + fs.writeFileSync(`${ssbFolder()}/secret`, submittedKey, { + mode: 0x100, + flag: "wx", + }); + fs.unlinkSync(`${ssbFolder()}/logged-out`); + } else { + res.cookie("ssb_key", JSON.stringify(decodedKey), cookieOptions); + await queries.autofollow(decodedKey.id); + } + + decodedKey.private = "[removed]"; + debug("Login with key", decodedKey); + + res.redirect("/"); }; router.get("/login", { public: true }, async (req, res) => { @@ -215,7 +234,11 @@ router.get("/download", { public: true }, (_req, res) => { }); router.get("/logout", async (_req, res) => { - res.clearCookie("ssb_key"); + if (mode == "standalone") { + fs.writeFileSync(`${ssbFolder()}/logged-out`, ""); + } else { + res.clearCookie("ssb_key"); + } res.redirect("/"); }); @@ -234,7 +257,13 @@ router.post("/signup", { public: true }, async (req, res) => { const pictureLink = picture && (await uploadPicture(ssb.client(), picture)); const key = await ssb.client().identities.createNewKey(); - res.cookie("ssb_key", JSON.stringify(key), cookieOptions); + if (mode == "standalone") { + fs.writeFileSync(`${ssbFolder()}/secret`, humanifyKey(key)); + fs.unlinkSync(`${ssbFolder()}/logged-out`); + } else { + res.cookie("ssb_key", JSON.stringify(key), cookieOptions); + await queries.autofollow(key.id); + } await ssb.client().identities.publishAs({ key, @@ -252,8 +281,6 @@ router.post("/signup", { public: true }, async (req, res) => { debug("Published about", { about: key.id, name, image: pictureLink }); - await queries.autofollow(key.id); - res.redirect("/keys"); }); @@ -305,24 +332,28 @@ router.get("/keys/copy", (req, res) => { }); }); +const humanifyKey = (key) => { + return ` + # WARNING: Never show this to anyone. + # WARNING: Never edit it or use it on multiple devices at once. + # + # This is your SECRET, it gives you magical powers. With your secret you can + # sign your messages so that your friends can verify that the messages came + # from you. If anyone learns your secret, they can use it to impersonate you. + # + # If you use this secret on more than one device you will create a fork and + # your friends will stop replicating your content. + # + ${JSON.stringify(key)} + # + # The only part of this file that's safe to share is your public name: + # + # ${key.id} + `; +}; + router.get("/keys/download", async (req, res) => { - const secretFile = ` -# WARNING: Never show this to anyone. -# WARNING: Never edit it or use it on multiple devices at once. -# -# This is your SECRET, it gives you magical powers. With your secret you can -# sign your messages so that your friends can verify that the messages came -# from you. If anyone learns your secret, they can use it to impersonate you. -# -# If you use this secret on more than one device you will create a fork and -# your friends will stop replicating your content. -# -${JSON.stringify(req.context.profile.key)} -# -# The only part of this file that's safe to share is your public name: -# -# ${req.context.profile.id} -`; + const secretFile = humanifyKey(req.context.profile.key); res.contentType("text/plain"); res.header("Content-Disposition", "attachment; filename=secret"); diff --git a/web/lib/ssb-client.js b/web/lib/ssb-client.js index be7d12a..65cebbb 100644 --- a/web/lib/ssb-client.js +++ b/web/lib/ssb-client.js @@ -14,27 +14,32 @@ let ssbSecret = ssbKeys.loadOrCreateSync( ); let syncing = false; -Client(ssbSecret, ssbConfig, async (err, server) => { - if (err) throw err; +const connectClient = (ssbSecret) => { + Client(ssbSecret, ssbConfig, async (err, server) => { + if (err) throw err; - ssbClient = server; + ssbClient = server; - queries.progress(({ rate, feeds, incompleteFeeds, progress, total }) => { - if (incompleteFeeds > 0) { - if (!syncing) debug("syncing"); - syncing = true; - } else { - syncing = false; - } + queries.progress(({ rate, feeds, incompleteFeeds, progress, total }) => { + if (incompleteFeeds > 0) { + if (!syncing) debug("syncing"); + syncing = true; + } else { + syncing = false; + } - metrics.ssbProgressRate.set(rate); - metrics.ssbProgressFeeds.set(feeds); - metrics.ssbProgressIncompleteFeeds.set(incompleteFeeds); - metrics.ssbProgressProgress.set(progress); - metrics.ssbProgressTotal.set(total); + metrics.ssbProgressRate.set(rate); + metrics.ssbProgressFeeds.set(feeds); + metrics.ssbProgressIncompleteFeeds.set(incompleteFeeds); + metrics.ssbProgressProgress.set(progress); + metrics.ssbProgressTotal.set(total); + }); + console.log("SSB Client ready"); }); - console.log("SSB Client ready"); -}); +}; module.exports.client = () => ssbClient; module.exports.isSyncing = () => syncing; +module.exports.reconnectWith = connectClient; + +connectClient(ssbSecret); diff --git a/web/lib/ssb.js b/web/lib/ssb.js index edcf49f..c87f46e 100644 --- a/web/lib/ssb.js +++ b/web/lib/ssb.js @@ -2,14 +2,14 @@ const fs = require("fs"); const path = require("path"); const { writeKey, ssbFolder } = require("./utils"); -let envKey = +const envKey = process.env.SSB_KEY && Buffer.from(process.env.SSB_KEY, "base64").toString("utf8"); -if (envKey) { - try { - writeKey(envKey, "/secret"); - console.log("Writing SSB_KEY from env"); - } catch (_) {} +const secretExists = fs.existsSync(`${ssbFolder()}/secret`); + +if (!secretExists && envKey) { + writeKey(envKey, "/secret"); + console.log("Writing SSB_KEY from env"); if (!fs.existsSync(`${ssbFolder()}/gossip.json`)) { fs.copyFileSync("gossip.json", `${ssbFolder()}/gossip.json`); } @@ -44,3 +44,9 @@ fs.writeFileSync( path.join(config.path, "manifest.json"), // ~/.ssb/manifest.json JSON.stringify(manifest) ); + +// SSB server automatically creates a secret key, but we want the user flow where they choose to create a key or use an existing one +const mode = process.env.MODE || "standalone"; +if (mode == "standalone" && !secretExists) { + fs.writeFileSync(`${ssbFolder()}/logged-out`, ""); +} diff --git a/web/package.json b/web/package.json index 340f139..eeb1dbe 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "start": "MODE=server SSB_PORT=8009 node index.js", - "start:client": "electron .", + "start:standalone": "electron .", "start:user-2": "SSB_PORT=8010 PORT=3001 CONFIG_FOLDER=feedless-user2 electron .", "start:user-3": "SSB_PORT=8011 PORT=3002 CONFIG_FOLDER=feedless-user3 electron .", "clear": "rm -rf ~/.feedless; rm -rf ~/.feedless-user2; rm -rf ~/.feedless-user3", @@ -58,4 +58,4 @@ "devDependencies": { "electron": "^8.2.0" } -} +} \ No newline at end of file