15 Commits

Author SHA1 Message Date
7e2eb2cb75 Merge pull request 'hotfix/desktop-cant-click-logo' (#22) from hotfix/desktop-cant-click-logo into master
Reviewed-on: #22
2022-02-25 04:07:08 +00:00
67b307612d let splash container not sit over logo, and also testing merge commit signing 2022-02-24 22:18:58 -05:00
3f7da22499 fix bug where logo isn't clickable on splash screen 2022-02-24 11:41:29 -05:00
50012ad7b3 better desktop styling, change some wording 2022-02-24 11:32:00 -05:00
1696995fb6 typo 2022-02-24 02:10:37 -05:00
04c96fafc4 change local file upload location 2022-02-24 02:09:40 -05:00
38a7ac597b force ssl 2022-02-24 01:41:14 -05:00
0847b0ff3d fix margin on splash page 2022-02-24 01:19:58 -05:00
2ccb3d0053 bug fixes, add logo, mobile styling 2022-02-24 01:15:44 -05:00
32cefda0a0 Merge pull request 'feature/properly-display-emails' (#13) from feature/properly-display-emails into master
Reviewed-on: #13
2022-02-22 22:34:02 +00:00
3c9dd96d8b properly display username/emails of logged in user, and add logout button 2022-02-22 17:32:45 -05:00
8330bb420e properly display encrypted text 2022-02-22 03:05:29 -05:00
621cbe867b Merge pull request 'feature/support-for-secret-files' (#12) from feature/support-for-secret-files into master
Reviewed-on: #12
2022-02-22 07:42:10 +00:00
263618a277 get secret files working 2022-02-22 02:34:45 -05:00
42cfe127eb Merge branch 'feature/support-for-usernames' 2022-02-22 00:44:59 -05:00
23 changed files with 519 additions and 241 deletions

4
.gitignore vendored
View File

@@ -35,5 +35,5 @@ npm-debug.log
/priv/cert/ /priv/cert/
dev.secret.exs dev.secret.exs
/uploads/* priv/uploads/*
!/uploads/.gitkeep !priv/uploads/.gitkeep

View File

@@ -12,15 +12,15 @@
transition: opacity 1s ease-out; transition: opacity 1s ease-out;
} }
.phx-disconnected{ .phx-disconnected {
cursor: wait; cursor: wait;
} }
.phx-disconnected *{ .phx-disconnected * {
pointer-events: none; pointer-events: none;
} }
.phx-modal { .phx-modal {
opacity: 1!important; opacity: 1 !important;
position: fixed; position: fixed;
z-index: 1; z-index: 1;
left: 0; left: 0;
@@ -28,8 +28,8 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: auto; overflow: auto;
background-color: rgb(0,0,0); background-color: rgb(0, 0, 0);
background-color: rgba(0,0,0,0.4); background-color: rgba(0, 0, 0, 0.4);
} }
.phx-modal-content { .phx-modal-content {
@@ -54,7 +54,6 @@
cursor: pointer; cursor: pointer;
} }
/* Alerts and form errors */ /* Alerts and form errors */
.alert { .alert {
padding: 15px; padding: 15px;
@@ -88,3 +87,47 @@
display: block; display: block;
margin: -1rem 0 2rem; margin: -1rem 0 2rem;
} }
.logo {
width: 200px;
padding: 20px;
border-right: 1px #efefef solid;
}
.centered-container {
margin-top: 3rem;
background: none !important;
height: auto;
}
html {
height: 100%;
}
@media screen and (min-width: 1025px) {
.centered-container {
margin-top: 0px;
position: absolute;
top: 50%;
transform: translate(0, -50%);
}
}
@media screen and (max-width: 1024px) {
.logo {
display: block;
margin-left: auto;
margin-right: auto;
border-right: none;
border-bottom: 1px #efefef solid;
}
.splashHeader {
font-size: 1.9rem;
}
.splashSubheader {
font-size: 1.4rem;
font-weight: 200;
}
}

View File

@@ -1,4 +1,18 @@
type IntendedUser = { type IntendedUser = {
name: string; name: string;
emails: string[]; emails: OAuthEmail[];
username: string;
};
type OAuthEmail = {
email: string;
primary: boolean;
verified: boolean;
};
type IntendedLink = {
filename: string | null,
filetype: string | null,
text_content: string | null,
file_content: string | null
}; };

View File

@@ -20,6 +20,7 @@ type AuthPageProps = {
service: string; service: string;
recipient: string; recipient: string;
user: IntendedUser | null; user: IntendedUser | null;
error: string;
}; };
interface Keys { interface Keys {
@@ -27,32 +28,47 @@ interface Keys {
iv: string; iv: string;
} }
interface Link { interface LinkFiles {
text: Blob | null; text: Blob | null;
file: Blob | null; file: Blob | null;
filename: string | null;
filetype: string | null;
} }
const AuthPage = (props: AuthPageProps) => { const AuthPage = (props: AuthPageProps) => {
const { service, recipient, user } = props; const { service, recipient, user } = props;
const [secretFileUrl, _setsecretFileUrl] = useState<string>("#"); const [secretFileUrl, setSecretFileUrl] = useState<string>("#");
const [secretMessage, setSecretMessage] = useState<string>("Decrypting..."); const [secretFileName, setSecretFileName] = useState<string>("");
const [secretMessage, setSecretMessage] = useState<string>("");
const [messageRevealed, setMessageRevealed] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
init().catch((reason) => { init().catch((reason) => {
alert(reason); console.log(reason);
}); });
}, []); }, []);
const capitalize = (s: string) =>
(s && s[0].toUpperCase() + s.slice(1)) || "";
const init = async (): Promise<void> => { const init = async (): Promise<void> => {
const link: LinkFiles | null = await retrieveLink();
const keys: Keys | null = await retrieveKeys(); const keys: Keys | null = await retrieveKeys();
const link: Link | null = await retrieveLink(); if (link && keys && user) {
if (link && keys) {
await decrypt(link, keys); await decrypt(link, keys);
} }
}; };
const retrieveLink = async (): Promise<Link | null> => { const userEmails = (): string[] => {
return user
? user.emails
.filter((email) => email.verified)
.map((email) => email.email)
: [];
};
const retrieveLink = async (): Promise<LinkFiles | null> => {
const urlSegments = new URL(document.URL).pathname.split("/"); const urlSegments = new URL(document.URL).pathname.split("/");
const linkId = urlSegments.pop() || urlSegments.pop(); const linkId = urlSegments.pop() || urlSegments.pop();
if (!linkId) { if (!linkId) {
@@ -60,14 +76,37 @@ const AuthPage = (props: AuthPageProps) => {
return null; return null;
} }
const textResponse = await fetch(`/uploads/links/${linkId}/text`); const linkResponse = await fetch(`/links/${linkId}`);
const textData = await textResponse.blob(); let linkData: IntendedLink | null;
const fileResponse = await fetch(`/uploads/links/${linkId}/file`); let textData = null;
const fileData = await fileResponse.blob(); let fileData = null;
if (linkResponse.status !== 200) {
throw new Error(linkResponse.statusText);
return null;
}
linkData = await linkResponse.json();
if (linkData) {
const textResponse = linkData.text_content
? await fetch(`/uploads/links/${linkId}/secret_message.txt`)
: null;
textData = textResponse ? await textResponse.blob() : null;
const fileResponse = linkData.file_content
? await fetch(`/uploads/links/${linkId}/${linkData.filename}`)
: null;
fileData = fileResponse ? await fileResponse.blob() : null;
if (linkData.filename) {
await setSecretFileName(linkData.filename);
}
}
return { return {
text: textData.size > 0 ? textData : null, text: textData,
file: fileData.size > 0 ? fileData : null, file: fileData,
filename: linkData ? linkData.filename : null,
filetype: linkData ? linkData.filetype : null,
}; };
}; };
@@ -79,13 +118,13 @@ const AuthPage = (props: AuthPageProps) => {
fragmentData[0] = fragmentData[0].slice(1); fragmentData[0] = fragmentData[0].slice(1);
if (fragmentData.length <= 1) { if (fragmentData.length <= 1) {
key = sessionStorage.getItem("link_key"); key = sessionStorage.getItem("key_hex");
iv = sessionStorage.getItem("link_iv"); iv = sessionStorage.getItem("iv_hex");
} else { } else {
key = fragmentData[0]; key = fragmentData[0];
iv = fragmentData[1]; iv = fragmentData[1];
sessionStorage.setItem("link_key", key); sessionStorage.setItem("key_hex", key);
sessionStorage.setItem("link_iv", iv); sessionStorage.setItem("iv_hex", iv);
} }
if (key && iv) { if (key && iv) {
@@ -96,7 +135,7 @@ const AuthPage = (props: AuthPageProps) => {
} }
}; };
const decrypt = async (link: Link, keys: Keys) => { const decrypt = async (link: LinkFiles, keys: Keys) => {
const convertedKey = HexMix.hexToUint8(keys.key); const convertedKey = HexMix.hexToUint8(keys.key);
const convertedIv = HexMix.hexToUint8(keys.iv); const convertedIv = HexMix.hexToUint8(keys.iv);
const importedKey = await window.crypto.subtle.importKey( const importedKey = await window.crypto.subtle.importKey(
@@ -106,6 +145,7 @@ const AuthPage = (props: AuthPageProps) => {
true, true,
["encrypt", "decrypt"] ["encrypt", "decrypt"]
); );
if (link?.text) { if (link?.text) {
const textFile = await link.text.arrayBuffer(); const textFile = await link.text.arrayBuffer();
const encodedText = await window.crypto.subtle.decrypt( const encodedText = await window.crypto.subtle.decrypt(
@@ -120,24 +160,76 @@ const AuthPage = (props: AuthPageProps) => {
// And voila // And voila
HexMix.arrayBufferToString(encodedText, (result: string) => { HexMix.arrayBufferToString(encodedText, (result: string) => {
setSecretMessage(result); setSecretMessage(result);
setMessageRevealed(true);
}); });
} }
if (link?.file) { if (link?.file) {
const uploadedFile = await link.file.arrayBuffer();
const encodedFile = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
length: 256,
iv: convertedIv,
},
importedKey,
uploadedFile
);
const blob = new Blob([encodedFile], {
type: link.filetype ? link.filetype : "text/plain",
});
setSecretFileUrl(window.URL.createObjectURL(blob));
setMessageRevealed(true);
} }
}; };
const renderFooter = (): JSX.Element => {
if (!user) return <div></div>;
return (
<Header3
small
style={{ color: "#CCCCCC", fontSize: "1.4rem", textAlign: "left" }}
>
Hello {user.name}, you are logged in to{" "}
<span style={{ color: "#A849CF" }}>{capitalize(service)}</span> as{" "}
<span style={{ color: "#32EFE7" }}>{user.username}</span>. This account
has the following emails associated with it:
<br />
<br />
<span style={{ color: "#32EFE7" }}>{userEmails().join(", ")}</span>
<br />
<br />
The intended recipient for this message is{" "}
<span style={{ color: "#32EFE7" }}>{recipient}</span> on{" "}
<span style={{ color: "#A849CF" }}>{capitalize(service)}</span>. If you
need to authenticate with a different account, you may do so by logging
out and accessing this link again. It's also possible that you have yet
to verify your email address on{" "}
<span style={{ color: "#A849CF" }}>{capitalize(service)}</span>.
</Header3>
);
};
const renderHeader = (): JSX.Element => { const renderHeader = (): JSX.Element => {
return ( return (
<div> <div>
<Header2 style={{ margin: ".4rem" }}>Someone sent you a secret</Header2> <Header2 style={{ marginBottom: ".4rem" }}>
{user ? "You have been identified!" : "Someone sent you a secret"}
</Header2>
{user ? ( {user ? (
<Header3 small> <Header3 small>
Hello {user.name}, you are logged in {service} which has the {messageRevealed
following verified emails: {user.emails.join(", ")} ? "The following message and/or file is for your eyes only."
: "Unfortunately, you are not the intended recipient."}
<br />
<br />
</Header3> </Header3>
) : ( ) : (
<Header3 small> <Header3 small>
Please verify your identity to reveal this message. The intended recipient for this message is {recipient} on{" "}
{capitalize(service)}. Please verify your identity to reveal this
message.
</Header3> </Header3>
)} )}
</div> </div>
@@ -146,7 +238,7 @@ const AuthPage = (props: AuthPageProps) => {
const renderAuth = (): JSX.Element => { const renderAuth = (): JSX.Element => {
return ( return (
<CenteredContainer fullscreen> <CenteredContainer fullscreen className="centered-container">
<CenteredContainer> <CenteredContainer>
{renderHeader()} {renderHeader()}
<Spacer space="3rem" /> <Spacer space="3rem" />
@@ -176,7 +268,7 @@ const AuthPage = (props: AuthPageProps) => {
const renderReveal = (): JSX.Element => { const renderReveal = (): JSX.Element => {
return ( return (
<CenteredContainer fullscreen> <CenteredContainer fullscreen className="centered-container">
<CenteredContainer> <CenteredContainer>
{renderHeader()} {renderHeader()}
<Spacer space="3rem" /> <Spacer space="3rem" />
@@ -193,18 +285,22 @@ const AuthPage = (props: AuthPageProps) => {
<TextAlignWrapper align="left"> <TextAlignWrapper align="left">
<Label htmlFor="service">Secret File</Label> <Label htmlFor="service">Secret File</Label>
</TextAlignWrapper> </TextAlignWrapper>
<a href={secretFileUrl} download style={{ width: "100%" }}>
<InputButtonWithIcon <InputButtonWithIcon
variant="download" variant="download"
id="downloadfile" id="downloadfile"
value={secretFileUrl} value={secretFileName}
onClick={() => {}} onClick={() => {}}
/> />
</a>
<Spacer space="3rem" /> <Spacer space="3rem" />
<a href={`https://intended.link/auth/${service}`}> <a href={`https://intended.link/auth/logout`}>
<Button variant="primary" wide onClick={() => {}}> <Button variant="primary" wide onClick={() => {}}>
Re-Verify Logout
</Button> </Button>
</a> </a>
<Spacer space="3rem" />
{renderFooter()}
</CenteredContainer> </CenteredContainer>
</CenteredContainer> </CenteredContainer>
); );

View File

@@ -1,11 +1,23 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ProgressIndicator, Header2, Button, IconArrow, Label, Input, Select, CenteredContainer, SpaceBetweenContainer, Spacer, TextAlignWrapper, GlobalStyle } from "@intended/intended-ui"; import {
ProgressIndicator,
Header2,
Button,
IconArrow,
Label,
Input,
Select,
CenteredContainer,
SpaceBetweenContainer,
Spacer,
TextAlignWrapper,
GlobalStyle,
} from "@intended/intended-ui";
type ForPageProps = { type ForPageProps = {
csrf: string csrf: string;
} };
const ForPage = (props: ForPageProps) => { const ForPage = (props: ForPageProps) => {
const [recipientInput, setRecipientInput] = useState(""); const [recipientInput, setRecipientInput] = useState("");
@@ -35,20 +47,20 @@ const ForPage = (props: ForPageProps) => {
} }
const formData = new FormData(); const formData = new FormData();
formData.append('recipient', recipientInput); formData.append("recipient", recipientInput);
formData.append('service', serviceSelect); formData.append("service", serviceSelect);
formData.append("link_id", linkId); formData.append("link_id", linkId);
try { try {
const results = await fetch(`${window.location.origin}/just/for`, { const results = await fetch(`${window.location.origin}/just/for`, {
headers: { headers: {
"X-CSRF-Token": props.csrf "X-CSRF-Token": props.csrf,
}, },
body: formData, body: formData,
method: "POST" method: "POST",
}); });
if (!results.ok) { if (!results.ok) {
throw new Error('Network response was not OK'); throw new Error("Network response was not OK");
} }
await results.json(); await results.json();
@@ -61,7 +73,7 @@ const ForPage = (props: ForPageProps) => {
return ( return (
<React.StrictMode> <React.StrictMode>
<GlobalStyle /> <GlobalStyle />
<CenteredContainer fullscreen> <CenteredContainer fullscreen className="centered-container">
<CenteredContainer> <CenteredContainer>
<ProgressIndicator currentProgress={2} /> <ProgressIndicator currentProgress={2} />
<Header2>Tell Someone</Header2> <Header2>Tell Someone</Header2>
@@ -79,7 +91,8 @@ const ForPage = (props: ForPageProps) => {
<Spacer space="2.5rem" /> <Spacer space="2.5rem" />
<TextAlignWrapper align="left"> <TextAlignWrapper align="left">
<Label htmlFor="serviceSelector"> <Label htmlFor="serviceSelector">
What type of account is the above username or email associated with? What type of account is the above username or email associated
with?
</Label> </Label>
</TextAlignWrapper> </TextAlignWrapper>
<Select <Select
@@ -87,11 +100,14 @@ const ForPage = (props: ForPageProps) => {
onChange={handleServiceChange} onChange={handleServiceChange}
value={serviceSelect} value={serviceSelect}
> >
<option value='github'>Github</option> <option value="github">Github</option>
</Select> </Select>
<Spacer space="3rem" /> <Spacer space="3rem" />
<SpaceBetweenContainer> <SpaceBetweenContainer>
<Button variant="secondary" onClick={() => window.location.href = "/just"}> <Button
variant="secondary"
onClick={() => (window.location.href = "/just")}
>
<IconArrow arrowDirection="left" /> <IconArrow arrowDirection="left" />
</Button> </Button>
<Button onClick={postContacts}>Generate Secret Code</Button> <Button onClick={postContacts}>Generate Secret Code</Button>

View File

@@ -1,80 +1,173 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { ProgressIndicator, Header2, Button, IconArrow, Label, FileInput, TextArea, CenteredContainer, Spacer, TextAlignWrapper, GlobalStyle } from '@intended/intended-ui'; import {
ProgressIndicator,
Header2,
Button,
IconArrow,
Label,
FileInput,
TextArea,
CenteredContainer,
Spacer,
TextAlignWrapper,
GlobalStyle,
} from "@intended/intended-ui";
import HexMix from "../utils/hexmix"; import HexMix from "../utils/hexmix";
type JustPageProps = { type JustPageProps = {
csrf: string csrf: string;
};
interface AESKey {
key: CryptoKey;
iv: Uint8Array;
exported: ArrayBuffer;
keyHex: string;
ivHex: string;
} }
const JustPage = (props: JustPageProps) => { const JustPage = (props: JustPageProps) => {
const [secretInput, setSecretInput] = useState(""); const [secretInput, setSecretInput] = useState("");
const [fileInput, setFileInput] = useState<File | null>(null); const [fileInput, setFileInput] = useState<string | null>(null);
const [fileName, setFileName] = useState(""); const [fileName, setFileName] = useState("");
const [fileType, setFileType] = useState("");
useEffect(() => { useEffect(() => {
sessionStorage.clear(); sessionStorage.clear();
}, []) }, []);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setSecretInput(e.target.value); setSecretInput(e.target.value);
}; };
const handleFile = (file: File) => { const handleFile = (file: File) => {
setFileInput(file);
setFileName(file.name); setFileName(file.name);
setFileType(file.type);
if (file instanceof File) {
if (file.size > 2097152) {
// TODO: may not need this check
alert("Error: Max file size is 2mb.");
return;
}
const reader = new FileReader();
reader.onloadend = loadFile;
reader.readAsArrayBuffer(file);
}
};
const loadFile = (fileEvent: ProgressEvent<FileReader>) => {
// Make sure we actually got a binary file
if (
fileEvent &&
fileEvent.target &&
fileEvent.target.result instanceof ArrayBuffer
) {
const data = fileEvent.target.result as ArrayBuffer;
HexMix.arrayBufferToString(data, (result: string) => {
setFileInput(result);
});
} else {
alert("File is either missing or corrupt.");
}
};
const fileFormData = async (
form: FormData,
aesKey: AESKey
): Promise<FormData> => {
if (!fileInput) return form;
const encoded = HexMix.stringToArrayBuffer(fileInput as string);
const encrypted = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: aesKey.iv,
},
aesKey.key,
encoded
);
const blobData = new Blob([encrypted]);
form.append("file_content", blobData, fileName);
form.append("filename", fileName);
form.append("filetype", fileType);
HexMix.arrayBufferToString(encrypted, (result: string) => {
sessionStorage.setItem("encoded_file", result);
});
return form;
};
const textFormData = async (
form: FormData,
aesKey: AESKey
): Promise<FormData> => {
if (!secretInput) return form;
const encoded = HexMix.stringToArrayBuffer(secretInput);
const encrypted = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: aesKey.iv,
},
aesKey.key,
encoded
);
const blobData = new Blob([encrypted]);
form.append("text_content", blobData, "secret_message.txt");
HexMix.arrayBufferToString(encrypted, (result: string) => {
sessionStorage.setItem("encoded_message", result);
});
return form;
};
const createKey = async (): Promise<AESKey> => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt", "decrypt"]
);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const exported = await window.crypto.subtle.exportKey("raw", key);
const keyHex = HexMix.uint8ToHex(new Uint8Array(exported));
const ivHex = HexMix.uint8ToHex(iv);
return {
key,
iv,
exported,
keyHex,
ivHex,
};
}; };
const postContents = async () => { const postContents = async () => {
if (!window.crypto.subtle) { if (!window.crypto.subtle) {
alert('Browser does not support SubtleCrypto'); alert("Browser does not support SubtleCrypto");
return; return;
} }
const key = await window.crypto.subtle.generateKey( const key = await createKey();
{ let formData = new FormData();
name: 'AES-GCM', formData = await fileFormData(formData, key);
length: 256 formData = await textFormData(formData, key);
},
true,
['encrypt', 'decrypt']
);
const encoded = HexMix.stringToArrayBuffer(secretInput);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const exported = await window.crypto.subtle.exportKey('raw', key);
const encrypted = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv
},
key,
encoded
);
const keyHex = HexMix.uint8ToHex(new Uint8Array(exported));
const ivHex = HexMix.uint8ToHex(iv);
const formData = new FormData();
const blobData = new Blob([encrypted]);
formData.append('text_content', blobData);
// formData.append('filetype', 'text/plain');
// formData.append('filename', 'secret.txt');
try { try {
const link: Response = await fetch(`${window.location.origin}/just`, { const link: Response = await fetch(`${window.location.origin}/just`, {
headers: { headers: {
"X-CSRF-Token": props.csrf "X-CSRF-Token": props.csrf,
}, },
body: formData, body: formData,
method: "POST" method: "POST",
}); });
const { id: link_id } = await link.json() const { id: link_id } = await link.json();
sessionStorage.setItem("link_id", link_id); sessionStorage.setItem("link_id", link_id);
sessionStorage.setItem("key_hex", keyHex); sessionStorage.setItem("key_hex", key.keyHex);
sessionStorage.setItem("iv_hex", ivHex); sessionStorage.setItem("iv_hex", key.ivHex);
window.location.href = `${window.location.origin}/just/for`; window.location.href = `${window.location.origin}/just/for`;
} catch (err: any) { } catch (err: any) {
alert(err.message); alert(err.message);
@@ -84,18 +177,18 @@ const JustPage = (props: JustPageProps) => {
return ( return (
<React.StrictMode> <React.StrictMode>
<GlobalStyle /> <GlobalStyle />
<CenteredContainer fullscreen> <CenteredContainer fullscreen className="centered-container">
<CenteredContainer> <CenteredContainer>
<ProgressIndicator currentProgress={1} /> <ProgressIndicator currentProgress={1} />
<Header2>Create a secret</Header2> <Header2>Create a secret</Header2>
<TextAlignWrapper align="left"> <TextAlignWrapper align="left">
<Label htmlFor="secretInput">Enter your secret here</Label> <Label htmlFor="secretInput">Enter a secret message</Label>
</TextAlignWrapper> </TextAlignWrapper>
<TextArea <TextArea
id="secretInput" id="secretInput"
value={secretInput} value={secretInput}
onChange={handleChange} onChange={handleChange}
placeholder="Tell me your secrets" placeholder="Only your intended recipient will see this message."
/> />
<Spacer space="2rem" /> <Spacer space="2rem" />
<TextAlignWrapper align="center"> <TextAlignWrapper align="center">
@@ -103,10 +196,13 @@ const JustPage = (props: JustPageProps) => {
</TextAlignWrapper> </TextAlignWrapper>
<Spacer space="1.6rem" /> <Spacer space="1.6rem" />
<FileInput id="fileInput" value={fileName} handleFile={handleFile} /> <FileInput id="fileInput" value={fileName} handleFile={handleFile} />
{ fileInput ? "" : ""}
<Spacer space="4rem" /> <Spacer space="4rem" />
<div <div
style={{ display: "flex", justifyContent: "flex-end", width: "100%" }} style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
}}
> >
<Button variant="secondary" onClick={postContents}> <Button variant="secondary" onClick={postContents}>
<IconArrow arrowDirection="right" /> <IconArrow arrowDirection="right" />

View File

@@ -1,21 +1,65 @@
import React from "react"; import React, { useEffect } from "react";
import { CenteredContainer, SplashIconHeader, Header1, Header3, Spacer, Button, GlobalStyle } from '@intended/intended-ui'; import {
CenteredContainer,
SplashIconHeader,
Header1,
Header3,
Spacer,
Button,
GlobalStyle,
} from "@intended/intended-ui";
type SplashPageProps = {
error: string;
};
const SplashPage = (props: SplashPageProps) => {
useEffect(() => {
displayErrors();
});
const displayErrors = () => {
const { error } = props;
if (error) alert(error);
};
const SplashPage = () => {
return ( return (
<React.StrictMode> <React.StrictMode>
<GlobalStyle /> <GlobalStyle />
<CenteredContainer fullscreen> <CenteredContainer
fullscreen
style={{
background: "none",
position: "absolute",
top: "50%",
transform: "translate(0, -50%)",
height: "auto",
}}
>
<CenteredContainer wide> <CenteredContainer wide>
<SplashIconHeader /> <SplashIconHeader style={{ width: "100%", maxWidth: "440px" }} />
<Header1>Securely Share Your Secrets</Header1> <Header1>
<span
className="splashHeader"
style={{ display: "block", marginTop: "20px" }}
>
Securely Share Your Secrets
</span>
</Header1>
<Header3> <Header3>
With Intended Link you can easily share messages and files securely <span className="splashSubheader">
and secretly. With Intended Link, you can send messages and files to any social
account in a secure and private manner.
</span>
</Header3> </Header3>
<Spacer /> <Spacer />
<Button variant="secondary" boldFont onClick={() => window.location.href = "/just"}> <Button
variant="secondary"
boldFont
onClick={() => (window.location.href = "/just")}
>
START SHARING START SHARING
</Button> </Button>
</CenteredContainer> </CenteredContainer>

View File

@@ -1,8 +1,29 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { ProgressIndicator, Header2, Button, IconArrow, InputButtonWithIcon, Label, Input, CenteredContainer, SpaceBetweenContainer, Spacer, TextAlignWrapper, GlobalStyle } from "@intended/intended-ui"; import {
ProgressIndicator,
Header2,
Button,
IconArrow,
InputButtonWithIcon,
Label,
Input,
CenteredContainer,
SpaceBetweenContainer,
Spacer,
TextAlignWrapper,
GlobalStyle,
} from "@intended/intended-ui";
const YouPage = () => { const YouPage = () => {
const [url, setUrl] = useState("#");
const [encoded, setEncoded] = useState("");
useEffect(() => {
setUrl(calculateUrl());
setEncoded(calculateEncoded());
}, []);
const calculateUrl = () => { const calculateUrl = () => {
const linkId = sessionStorage.getItem("link_id"); const linkId = sessionStorage.getItem("link_id");
const keyHex = sessionStorage.getItem("key_hex"); const keyHex = sessionStorage.getItem("key_hex");
@@ -11,7 +32,11 @@ const YouPage = () => {
return `${window.location.origin}/just/for/you/${linkId}#${keyHex}.${ivHex}`; return `${window.location.origin}/just/for/you/${linkId}#${keyHex}.${ivHex}`;
}; };
const [url, _setUrl] = useState(calculateUrl()); const calculateEncoded = () => {
const encodedFile = sessionStorage.getItem("encoded_file");
const encodedMessage = sessionStorage.getItem("encoded_message");
return `${encodedMessage}${encodedFile}`;
};
const copyUrl = async () => { const copyUrl = async () => {
try { try {
@@ -19,12 +44,12 @@ const YouPage = () => {
} catch (err: any) { } catch (err: any) {
alert("Could not copy url to clipboard."); alert("Could not copy url to clipboard.");
} }
} };
return ( return (
<React.StrictMode> <React.StrictMode>
<GlobalStyle /> <GlobalStyle />
<CenteredContainer fullscreen> <CenteredContainer fullscreen className="centered-container">
<CenteredContainer> <CenteredContainer>
<ProgressIndicator currentProgress={3} /> <ProgressIndicator currentProgress={3} />
<Header2>Share the secret</Header2> <Header2>Share the secret</Header2>
@@ -47,20 +72,23 @@ const YouPage = () => {
looking eh?: looking eh?:
</Label> </Label>
</TextAlignWrapper> </TextAlignWrapper>
<Input <Input variant="disabled-light" id="encodedSecret" value={encoded} />
variant="disabled-light"
id="encodedSecret"
value={url}
/>
<Spacer space="3rem" /> <Spacer space="3rem" />
<SpaceBetweenContainer> <SpaceBetweenContainer>
<Button variant="secondary" onClick={() => window.location.href = "/just/for"}> <Button
variant="secondary"
onClick={() => (window.location.href = "/just/for")}
>
<IconArrow arrowDirection="left" /> <IconArrow arrowDirection="left" />
</Button> </Button>
<Button onClick={() => { <Button
onClick={() => {
sessionStorage.clear(); sessionStorage.clear();
window.location.href = "/just"; window.location.href = "/just";
}}>Create Another Secret</Button> }}
>
Create Another Secret
</Button>
</SpaceBetweenContainer> </SpaceBetweenContainer>
</CenteredContainer> </CenteredContainer>
</CenteredContainer> </CenteredContainer>

View File

@@ -1143,9 +1143,9 @@
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
}, },
"@intended/intended-ui": { "@intended/intended-ui": {
"version": "0.1.21", "version": "0.1.26",
"resolved": "https://registry.npmjs.org/@intended/intended-ui/-/intended-ui-0.1.21.tgz", "resolved": "https://registry.npmjs.org/@intended/intended-ui/-/intended-ui-0.1.26.tgz",
"integrity": "sha512-rNyJOGLOw8iKP0AaSYSytZXFssPhlE1FbUjxEze8F4iOgXxs6iu7B9+gSIF4QJg/IrQUVu76tkevBGDk2nTQNg==", "integrity": "sha512-+fSZctq4ywDUN6IJ+SbQyGhcx1fZmdyq/zsDqveJShFOGLOU39l5tT34/QHBScXlSLwgAkosCycldWyfW/olUg==",
"requires": { "requires": {
"polished": "^4.1.3", "polished": "^4.1.3",
"react": "^17.0.2", "react": "^17.0.2",
@@ -1720,14 +1720,15 @@
} }
}, },
"babel-plugin-styled-components": { "babel-plugin-styled-components": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.0.2.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.0.5.tgz",
"integrity": "sha512-7eG5NE8rChnNTDxa6LQfynwgHTVOYYaHJbUYSlOhk8QBXIQiMBKq4gyfHBBKPrxUcVBXVJL61ihduCpCQbuNbw==", "integrity": "sha512-A7kfST5odbf8Ev42OQbj5teEiT8DskpRoQ/iPYePLLdcTCAsodpYKqtoy4SJthpsGzQKc2vvnrtlUgdmJq6WKQ==",
"requires": { "requires": {
"@babel/helper-annotate-as-pure": "^7.16.0", "@babel/helper-annotate-as-pure": "^7.16.0",
"@babel/helper-module-imports": "^7.16.0", "@babel/helper-module-imports": "^7.16.0",
"babel-plugin-syntax-jsx": "^6.18.0", "babel-plugin-syntax-jsx": "^6.18.0",
"lodash": "^4.17.11" "lodash": "^4.17.11",
"picomatch": "^2.3.0"
} }
}, },
"babel-plugin-syntax-jsx": { "babel-plugin-syntax-jsx": {
@@ -5863,8 +5864,7 @@
"picomatch": { "picomatch": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
"integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw=="
"dev": true
}, },
"pify": { "pify": {
"version": "4.0.1", "version": "4.0.1",

View File

@@ -7,7 +7,7 @@
"watch": "webpack --mode development --watch" "watch": "webpack --mode development --watch"
}, },
"dependencies": { "dependencies": {
"@intended/intended-ui": "0.1.21", "@intended/intended-ui": "0.1.26",
"phoenix": "file:../deps/phoenix", "phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html", "phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view", "phoenix_live_view": "file:../deps/phoenix_live_view",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -10,7 +10,7 @@ use Mix.Config
# which you should run after static files are built and # which you should run after static files are built and
# before starting your production server. # before starting your production server.
config :entendu, EntenduWeb.Endpoint, config :entendu, EntenduWeb.Endpoint,
url: [host: "example.com", port: 80], url: [host: "intended.link", port: 80],
cache_static_manifest: "priv/static/cache_manifest.json" cache_static_manifest: "priv/static/cache_manifest.json"
# Do not print debug messages in production # Do not print debug messages in production
@@ -45,8 +45,7 @@ config :logger, level: :info
# We also recommend setting `force_ssl` in your endpoint, ensuring # We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https: # no data is ever sent via http, always redirecting to https:
# #
# config :entendu, EntenduWeb.Endpoint, config :my_app, EntenduWeb.Endpoint, force_ssl: [rewrite_on: [:x_forwarded_proto]]
# force_ssl: [hsts: true]
# #
# Check `Plug.SSL` for all available options in `force_ssl`. # Check `Plug.SSL` for all available options in `force_ssl`.

View File

@@ -28,8 +28,7 @@ defmodule EntenduWeb.AuthController do
link = get_session(conn, :intended_link) link = get_session(conn, :intended_link)
with %{id: link_id, recipient: recipient} <- link, with %{id: link_id, recipient: recipient} <- link,
{:ok, user} <- UserFromAuth.find_or_create(auth), {:ok, user} <- UserFromAuth.find_or_create(auth) do
true <- UserFromAuth.can_access?(recipient, user) do
# TODO: send over encrypted data that the frontend can decrypt # TODO: send over encrypted data that the frontend can decrypt
conn conn
@@ -42,11 +41,6 @@ defmodule EntenduWeb.AuthController do
|> put_flash(:error, "Could not find link to authenticate against") |> put_flash(:error, "Could not find link to authenticate against")
|> redirect(to: "/just/for/you/") |> redirect(to: "/just/for/you/")
false ->
conn
|> put_flash(:error, "#{link.recipient} was not found in your list of verified emails")
|> redirect(to: "/just/for/you/#{link.id}")
{:error, reason} -> {:error, reason} ->
conn conn
|> put_flash(:error, reason) |> put_flash(:error, reason)

View File

@@ -36,6 +36,7 @@ defmodule EntenduWeb.LinkController do
with %Link{} = link <- Links.get_link(link_id), with %Link{} = link <- Links.get_link(link_id),
Links.update_link(link, %{recipient: recipient, service: service}) do Links.update_link(link, %{recipient: recipient, service: service}) do
conn conn
|> put_session(:intended_link, %{})
|> render("show_authorized.json", %{link: link}) |> render("show_authorized.json", %{link: link})
end end
end end
@@ -45,28 +46,17 @@ defmodule EntenduWeb.LinkController do
end end
def auth_page(conn, %{"id" => link_id}) do def auth_page(conn, %{"id" => link_id}) do
with %Link{service: service, recipient: recipient} = link <- Links.get_link(link_id) do with %Link{id: id, service: service, recipient: recipient} = link <- Links.get_link(link_id) do
conn conn
|> put_session(:intended_link, link) |> put_session(:intended_link, %{id: id, service: service, recipient: recipient})
|> render("auth.html", %{intended_link: %{service: service, recipient: recipient}}) |> render("auth.html", %{intended_link: %{service: service, recipient: recipient}})
end end
end end
def text(conn, %{"id" => link_id}) do def authorized_link(conn, %{"id" => link_id}) do
with user = get_session(conn, :current_user), with %Link{} = link <- Links.get_link(link_id) do
%Link{recipient: recipient} = link <- Links.get_link(link_id), conn
true <- UserFromAuth.can_access?(recipient, user) do |> render("show_authorized.json", %{link: link})
path = EncryptedLink.url({link.text_content, link})
send_file(conn, 200, path)
end
end
def file(conn, %{"id" => link_id}) do
with user = get_session(conn, :current_user),
%Link{recipient: recipient} = link <- Links.get_link(link_id),
true <- UserFromAuth.can_access?(recipient, user) do
path = EncryptedLink.url({link.file_content, link})
send_file(conn, 200, path)
end end
end end
end end

View File

@@ -6,6 +6,8 @@ defmodule EntenduWeb.PageController do
use EntenduWeb, :controller use EntenduWeb, :controller
def index(conn, _params) do def index(conn, _params) do
render(conn, "index.html", current_user: get_session(conn, :current_user)) conn
|> clear_session()
|> render("index.html")
end end
end end

View File

@@ -12,8 +12,12 @@ defmodule EntenduWeb.Plugs.AuthorizeLink do
def init(_params) do def init(_params) do
end end
def call(conn, params) do defp get_link_id(%{params: %{"id" => link_id}}), do: link_id
%{params: %{"path" => [_, link_id, _]}} = conn
defp get_link_id(%{params: %{"path" => [_, link_id, _]}}), do: link_id
def call(conn, _params) do
link_id = get_link_id(conn)
user = get_session(conn, :current_user) user = get_session(conn, :current_user)
if !user do if !user do
@@ -23,8 +27,7 @@ defmodule EntenduWeb.Plugs.AuthorizeLink do
|> render("error_code.json", message: "Unauthorized", code: 403) |> render("error_code.json", message: "Unauthorized", code: 403)
|> halt |> halt
else else
with {:ok, user} <- get_user_from_path(conn), with %Link{recipient: recipient} = link <- Links.get_link(link_id),
%Link{recipient: recipient} = link <- Links.get_link(link_id),
true <- UserFromAuth.can_access?(recipient, user) do true <- UserFromAuth.can_access?(recipient, user) do
conn conn
|> assign(:link, link) |> assign(:link, link)
@@ -52,19 +55,4 @@ defmodule EntenduWeb.Plugs.AuthorizeLink do
end end
end end
end end
defp get_user_from_path(%{params: %{"path" => [_, link_id, _]}} = conn) do
get_session(conn, :current_user)
|> get_user_from_path()
end
defp get_user_from_path(nil) do
{:error, "User not authenticated"}
end
defp get_user_from_path(%{id: _, name: _, emails: _} = user) do
{:ok, user}
end
defp get_user_from_path(_), do: {:error, "Link does not exist"}
end end

View File

@@ -16,9 +16,13 @@ defmodule EntenduWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
pipeline :authorized_links do pipeline :authorized_files do
plug AuthorizeLink
plug Plug.Static, at: "/uploads", from: {:entendu, "priv/uploads"}, gzip: false
end
pipeline :authorized_link do
plug AuthorizeLink plug AuthorizeLink
plug Plug.Static, at: "/uploads", from: Path.expand('./uploads'), gzip: false
end end
scope "/", EntenduWeb do scope "/", EntenduWeb do
@@ -31,23 +35,26 @@ defmodule EntenduWeb.Router do
post "/just/for", LinkController, :for post "/just/for", LinkController, :for
get "/just/for/you", LinkController, :you_page get "/just/for/you", LinkController, :you_page
get "/just/for/you/:id", LinkController, :auth_page get "/just/for/you/:id", LinkController, :auth_page
get "/links/:id/text", LinkController, :text
get "/links/:id/file", LinkController, :file
end end
scope "/auth", EntenduWeb do scope "/auth", EntenduWeb do
pipe_through :browser pipe_through :browser
get "/logout", AuthController, :delete
get "/:provider", AuthController, :request get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback get "/:provider/callback", AuthController, :callback
delete "/logout", AuthController, :delete
end end
scope "/uploads", EntenduWeb do scope "/uploads", EntenduWeb do
pipe_through [:browser, :authorized_links] pipe_through [:browser, :authorized_files]
get "/*path", FileNotFoundController, :index get "/*path", FileNotFoundController, :index
end end
scope "/links", EntenduWeb do
pipe_through [:browser, :authorized_link]
get "/:id", LinkController, :authorized_link
end
# Other scopes may use custom stacks. # Other scopes may use custom stacks.
# scope "/api", EntenduWeb do # scope "/api", EntenduWeb do
# pipe_through :api # pipe_through :api

View File

@@ -1,5 +1,6 @@
<main role="main"> <main role="main">
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> <a href="/">
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> <img src="<%= Routes.static_path(@conn, "/images/logo.png") %>" class="logo" />
</a>
<%= @inner_content %> <%= @inner_content %>
</main> </main>

View File

@@ -9,7 +9,7 @@
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/> <link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script> <script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head> </head>
<body style="background: #060b2e;"> <body style="background: linear-gradient(180deg,#060b2e 0%,#051745 100%); min-height: 100%;">
<%= @inner_content %> <%= @inner_content %>
</body> </body>
</html> </html>

View File

@@ -3,5 +3,6 @@
service: @intended_link.service, service: @intended_link.service,
recipient: @intended_link.recipient, recipient: @intended_link.recipient,
user: current_user(@conn), user: current_user(@conn),
link: current_link(@conn) link: current_link(@conn),
error: get_flash(@conn, :error)
}) %> }) %>

View File

@@ -1,46 +1,5 @@
<section> <section>
<%= react_component("Components.SplashPage") %> <%= react_component("Components.SplashPage", %{ error: get_flash(@conn, :error) }) %>
<%= if @current_user do %>
<h2>Welcome, <%= @current_user.name %>!</h2>
<div>
<img src="<%= @current_user.avatar %>" />
</div>
<%= link "Logout", to: Routes.auth_path(@conn, :delete), method: "delete", class: "button" %>
<br>
<% else %>
<ul style="display: none;">
<li>
<a class="button" href="<%= Routes.auth_path(@conn, :request, "github") %>">
<i class="fa fa-github"></i>
Sign in with GitHub
</a>
</li>
<li>
<a class="button" href="<%= Routes.auth_path(@conn, :request, "facebook") %>">
<i class="fa fa-facebook"></i>
Sign in with Facebook
</a>
</li>
<li>
<a class="button" href="<%= Routes.auth_path(@conn, :request, "google") %>">
<i class="fa fa-google"></i>
Sign in with Google
</a>
</li>
<li>
<a class="button" href="<%= Routes.auth_path(@conn, :request, "slack") %>">
<i class="fa fa-slack"></i>
Sign in with Slack
</a>
</li>
<li>
<a class="button" href="<%= Routes.auth_path(@conn, :request, "twitter") %>">
<i class="fa fa-twitter"></i>
Sign in with Twitter
</a>
</li>
</ul>
<% end %>
</section> </section>

View File

@@ -31,13 +31,13 @@ defmodule Entendu.EncryptedLink do
# end # end
# Override the persisted filenames: # Override the persisted filenames:
def filename(_version, {_file, %{filename: filename}}) do # def filename(_version, {_file, %{filename: filename}}) do
if filename, do: filename, else: "text" # if filename, do: filename, else: "text"
end # end
# Override the storage directory: # Override the storage directory:
def storage_dir(version, {_file, scope}) do def storage_dir(version, {_file, scope}) do
"uploads/links/#{scope.id}" "priv/uploads/links/#{scope.id}"
end end
# Provide a default URL if there hasn't been a file uploaded # Provide a default URL if there hasn't been a file uploaded