Compare commits

..

No commits in common. "master" and "feature/support-for-usernames" have entirely different histories.

35 changed files with 269 additions and 4327 deletions

4
.gitignore vendored
View File

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

View File

@ -1,157 +0,0 @@
### GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates the
terms and conditions of version 3 of the GNU General Public License,
supplemented by the additional permissions listed below.
#### 0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the
GNU General Public License.
"The Library" refers to a covered work governed by this License, other
than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
#### 1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
#### 2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
- a) under this License, provided that you make a good faith effort
to ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
- b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
#### 3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from a
header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
- a) Give prominent notice with each copy of the object code that
the Library is used in it and that the Library and its use are
covered by this License.
- b) Accompany the object code with a copy of the GNU GPL and this
license document.
#### 4. Combined Works.
You may convey a Combined Work under terms of your choice that, taken
together, effectively do not restrict modification of the portions of
the Library contained in the Combined Work and reverse engineering for
debugging such modifications, if you also do each of the following:
- a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
- b) Accompany the Combined Work with a copy of the GNU GPL and this
license document.
- c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
- d) Do one of the following:
- 0. Convey the Minimal Corresponding Source under the terms of
this License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
- 1. Use a suitable shared library mechanism for linking with
the Library. A suitable mechanism is one that (a) uses at run
time a copy of the Library already present on the user's
computer system, and (b) will operate properly with a modified
version of the Library that is interface-compatible with the
Linked Version.
- e) Provide Installation Information, but only if you would
otherwise be required to provide such information under section 6
of the GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the Application
with a modified version of the Linked Version. (If you use option
4d0, the Installation Information must accompany the Minimal
Corresponding Source and Corresponding Application Code. If you
use option 4d1, you must provide the Installation Information in
the manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.)
#### 5. Combined Libraries.
You may place library facilities that are a work based on the Library
side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
- a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities, conveyed under the terms of this License.
- b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
#### 6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
as you received it specifies that a certain numbered version of the
GNU Lesser General Public License "or any later version" applies to
it, you have the option of following the terms and conditions either
of that published version or of any later version published by the
Free Software Foundation. If the Library as you received it does not
specify a version number of the GNU Lesser General Public License, you
may choose any version of the GNU Lesser General Public License ever
published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

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,6 +54,7 @@
cursor: pointer; cursor: pointer;
} }
/* Alerts and form errors */ /* Alerts and form errors */
.alert { .alert {
padding: 15px; padding: 15px;
@ -87,47 +88,3 @@
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,9 +0,0 @@
import type {Config} from '@jest/types';
// Sync object
const config: Config.InitialOptions = {
verbose: true,
transform: {
^.+\\.tsx?$: ts-jest,
},
};
export default config;

View File

@ -24,7 +24,6 @@ import JustPage from './pages/JustPage';
import ForPage from './pages/ForPage'; import ForPage from './pages/ForPage';
import YouPage from './pages/YouPage'; import YouPage from './pages/YouPage';
import AuthPage from './pages/AuthPage'; import AuthPage from './pages/AuthPage';
import PrivacyPolicyPage from './pages/PrivacyPolicyPage';
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
@ -44,5 +43,5 @@ liveSocket.connect()
window.liveSocket = liveSocket window.liveSocket = liveSocket
window.Components = { window.Components = {
SplashPage, JustPage, ForPage, YouPage, AuthPage, PrivacyPolicyPage SplashPage, JustPage, ForPage, YouPage, AuthPage
} }

View File

@ -1,18 +1,4 @@
type IntendedUser = { type IntendedUser = {
name: string; name: string;
emails: OAuthEmail[]; emails: string[];
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,7 +20,6 @@ type AuthPageProps = {
service: string; service: string;
recipient: string; recipient: string;
user: IntendedUser | null; user: IntendedUser | null;
error: string;
}; };
interface Keys { interface Keys {
@ -28,107 +27,47 @@ interface Keys {
iv: string; iv: string;
} }
interface LinkFiles { interface Link {
text: Blob | null; text: Blob | null;
file: Blob | null; file: Blob | null;
filename: string | null;
filetype: string | null;
}
interface GithubEmail {
email: string;
verified: boolean;
} }
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 [secretFileName, setSecretFileName] = useState<string>(""); const [secretMessage, setSecretMessage] = useState<string>("Decrypting...");
const [secretMessage, setSecretMessage] = useState<string>("");
const [messageRevealed, setMessageRevealed] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
init().catch((reason) => { init().catch((reason) => {
console.log(reason); alert(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();
if (link && keys && user) { const link: Link | null = await retrieveLink();
if (link && keys) {
await decrypt(link, keys); await decrypt(link, keys);
} }
}; };
const userEmails = (): string[] => { const retrieveLink = async (): Promise<Link | null> => {
if (!user?.emails) return [];
if (user.emails.length <= 0) return [];
return user
? user.emails
.filter(verifiedUserEmails)
.map((email) => (typeof email == "string" ? email : email.email))
: [];
};
const isGithubEmail = (email: string | GithubEmail): email is GithubEmail =>
(email as GithubEmail).verified !== undefined;
const verifiedUserEmails = (email: string | GithubEmail) => {
if (isGithubEmail(email)) {
return (email as GithubEmail).verified;
} else {
return true;
}
};
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) {
alert("Could not find intended link in URL"); alert("Could not find intended link in URL");
return null; return null;
} }
if (!user) {
// no need to retrieve link if they weren't authenticated
return null;
}
const linkResponse = await fetch(`/links/${linkId}`); const textResponse = await fetch(`/uploads/links/${linkId}/text`);
let linkData: IntendedLink | null; const textData = await textResponse.blob();
let textData = null; const fileResponse = await fetch(`/uploads/links/${linkId}/file`);
let fileData = null; const fileData = await fileResponse.blob();
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, text: textData.size > 0 ? textData : null,
file: fileData, file: fileData.size > 0 ? fileData : null,
filename: linkData ? linkData.filename : null,
filetype: linkData ? linkData.filetype : null,
}; };
}; };
@ -140,13 +79,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("key_hex"); key = sessionStorage.getItem("link_key");
iv = sessionStorage.getItem("iv_hex"); iv = sessionStorage.getItem("link_iv");
} else { } else {
key = fragmentData[0]; key = fragmentData[0];
iv = fragmentData[1]; iv = fragmentData[1];
sessionStorage.setItem("key_hex", key); sessionStorage.setItem("link_key", key);
sessionStorage.setItem("iv_hex", iv); sessionStorage.setItem("link_iv", iv);
} }
if (key && iv) { if (key && iv) {
@ -157,7 +96,7 @@ const AuthPage = (props: AuthPageProps) => {
} }
}; };
const decrypt = async (link: LinkFiles, keys: Keys) => { const decrypt = async (link: Link, 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(
@ -167,7 +106,6 @@ 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(
@ -182,79 +120,24 @@ 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 ? ` ${user.name}` : ""}! You are logged in to{" "}
<span style={{ color: "#A849CF" }}>{capitalize(service)}</span>
{user.username ? " as " : ""}
<span style={{ color: "#32EFE7" }}>
{user.username ? `${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={{ marginBottom: ".4rem" }}> <Header2 style={{ margin: ".4rem" }}>Someone sent you a secret</Header2>
{user ? "You have been identified!" : "Someone sent you a secret"}
</Header2>
{user ? ( {user ? (
<Header3 small> <Header3 small>
{messageRevealed Hello {user.name}, you are logged in {service} which has the
? "The following message and/or file is for your eyes only." following verified emails: {user.emails.join(", ")}
: "Unfortunately, you are not the intended recipient."}
<br />
<br />
</Header3> </Header3>
) : ( ) : (
<Header3 small> <Header3 small>
The intended recipient for this message is {recipient} on{" "} Please verify your identity to reveal this message.
{capitalize(service)}. Please verify your identity to reveal this
message.
</Header3> </Header3>
)} )}
</div> </div>
@ -263,7 +146,7 @@ const AuthPage = (props: AuthPageProps) => {
const renderAuth = (): JSX.Element => { const renderAuth = (): JSX.Element => {
return ( return (
<CenteredContainer fullscreen className="centered-container"> <CenteredContainer fullscreen>
<CenteredContainer> <CenteredContainer>
{renderHeader()} {renderHeader()}
<Spacer space="3rem" /> <Spacer space="3rem" />
@ -293,7 +176,7 @@ const AuthPage = (props: AuthPageProps) => {
const renderReveal = (): JSX.Element => { const renderReveal = (): JSX.Element => {
return ( return (
<CenteredContainer fullscreen className="centered-container"> <CenteredContainer fullscreen>
<CenteredContainer> <CenteredContainer>
{renderHeader()} {renderHeader()}
<Spacer space="3rem" /> <Spacer space="3rem" />
@ -310,22 +193,18 @@ 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/logout`}> <a href={`https://intended.link/auth/${service}`}>
<Button variant="primary" wide onClick={() => {}}> <Button variant="primary" wide onClick={() => {}}>
Logout Re-Verify
</Button> </Button>
</a> </a>
<Spacer space="3rem" />
{renderFooter()}
</CenteredContainer> </CenteredContainer>
</CenteredContainer> </CenteredContainer>
); );

View File

@ -1,23 +1,11 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { import { ProgressIndicator, Header2, Button, IconArrow, Label, Input, Select, CenteredContainer, SpaceBetweenContainer, Spacer, TextAlignWrapper, GlobalStyle } from "@intended/intended-ui";
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("");
@ -34,6 +22,12 @@ const ForPage = (props: ForPageProps) => {
}; };
const postContacts = async () => { const postContacts = async () => {
// const fragmentData = window.location.hash.split('.');
// if (fragmentData.length <= 0) {
// alert("No key found in fragment URI");
// return;
// }
const linkId = sessionStorage.getItem("link_id"); const linkId = sessionStorage.getItem("link_id");
if (!linkId) { if (!linkId) {
alert("No created link found in storage"); alert("No created link found in storage");
@ -41,20 +35,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();
@ -67,7 +61,7 @@ const ForPage = (props: ForPageProps) => {
return ( return (
<React.StrictMode> <React.StrictMode>
<GlobalStyle /> <GlobalStyle />
<CenteredContainer fullscreen className="centered-container"> <CenteredContainer fullscreen>
<CenteredContainer> <CenteredContainer>
<ProgressIndicator currentProgress={2} /> <ProgressIndicator currentProgress={2} />
<Header2>Tell Someone</Header2> <Header2>Tell Someone</Header2>
@ -85,8 +79,7 @@ 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 What type of account is the above username or email associated with?
with?
</Label> </Label>
</TextAlignWrapper> </TextAlignWrapper>
<Select <Select
@ -94,15 +87,11 @@ const ForPage = (props: ForPageProps) => {
onChange={handleServiceChange} onChange={handleServiceChange}
value={serviceSelect} value={serviceSelect}
> >
<option value="github">Github</option> <option value='github'>Github</option>
<option value="google">Gmail</option> </Select>
</Select>
<Spacer space="3rem" /> <Spacer space="3rem" />
<SpaceBetweenContainer> <SpaceBetweenContainer>
<Button <Button variant="secondary" onClick={() => window.location.href = "/just"}>
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,173 +1,80 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { import { ProgressIndicator, Header2, Button, IconArrow, Label, FileInput, TextArea, CenteredContainer, Spacer, TextAlignWrapper, GlobalStyle } from '@intended/intended-ui';
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<string | null>(null); const [fileInput, setFileInput] = useState<File | 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 createKey(); const key = await window.crypto.subtle.generateKey(
let formData = new FormData(); {
formData = await fileFormData(formData, key); name: 'AES-GCM',
formData = await textFormData(formData, key); length: 256
},
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", key.keyHex); sessionStorage.setItem("key_hex", keyHex);
sessionStorage.setItem("iv_hex", key.ivHex); sessionStorage.setItem("iv_hex", 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);
@ -177,18 +84,18 @@ const JustPage = (props: JustPageProps) => {
return ( return (
<React.StrictMode> <React.StrictMode>
<GlobalStyle /> <GlobalStyle />
<CenteredContainer fullscreen className="centered-container"> <CenteredContainer fullscreen>
<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 a secret message</Label> <Label htmlFor="secretInput">Enter your secret here</Label>
</TextAlignWrapper> </TextAlignWrapper>
<TextArea <TextArea
id="secretInput" id="secretInput"
value={secretInput} value={secretInput}
onChange={handleChange} onChange={handleChange}
placeholder="Only your intended recipient will see this message." placeholder="Tell me your secrets"
/> />
<Spacer space="2rem" /> <Spacer space="2rem" />
<TextAlignWrapper align="center"> <TextAlignWrapper align="center">
@ -196,13 +103,10 @@ 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={{ style={{ display: "flex", justifyContent: "flex-end", width: "100%" }}
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,62 +0,0 @@
import React from "react";
import {
CenteredContainer,
Header1,
Header3,
GlobalStyle,
} from "@intended/intended-ui";
const PrivacyPolicyPage = () => {
return (
<React.StrictMode>
<GlobalStyle />
<CenteredContainer
fullscreen
style={{
background: "none",
height: "auto",
}}
>
<CenteredContainer wide style={{ maxWidth: "800px" }}>
<Header1>Privacy Policy</Header1>
<Header3
small
style={{
color: "#CCCCCC",
textAlign: "left",
fontSize: "18px",
lineHeight: 1.6,
}}
>
<p>
This instance of Intended Link collects as little data as
necessary to provide its service. It can not read the secret
message and secret file, and all data associated with a link is
deleted once it expires.
</p>
<p>
Each link created will store the recipient's username or email and
its associated service for authorization purposes. It will store
the filename and filetype of the secret file, if it exists, to
make it easier for users to download and use the file once it's
decrypted. We store the timestamps of when the Link was created
and updated, along with when the link should expire.
</p>
<p>
When you authenticate with one of our supported OAuth providers,
we receive the third party's response, and if verification was
successful, we store the account's username and verified emails in
a short-lived session store. This data is then used to determine
whether the user is permitted to download the link's associated
secret message and file.
</p>
<p>This software is licensed under LGPL.</p>
</Header3>
</CenteredContainer>
</CenteredContainer>
</React.StrictMode>
);
};
export default PrivacyPolicyPage;

View File

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

View File

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

3507
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,10 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"deploy": "webpack --mode production", "deploy": "webpack --mode production",
"watch": "webpack --mode development --watch", "watch": "webpack --mode development --watch"
"test": "jest"
}, },
"dependencies": { "dependencies": {
"@intended/intended-ui": "0.1.26", "@intended/intended-ui": "0.1.21",
"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",
@ -21,7 +20,6 @@
"@babel/core": "^7.0.0", "@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0", "@babel/preset-env": "^7.0.0",
"@babel/preset-react": "^7.16.0", "@babel/preset-react": "^7.16.0",
"@types/jest": "^27.4.1",
"@types/phoenix": "^1.5.3", "@types/phoenix": "^1.5.3",
"@types/react": "^17.0.37", "@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",
@ -29,14 +27,12 @@
"copy-webpack-plugin": "^5.1.1", "copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.2", "css-loader": "^3.4.2",
"hard-source-webpack-plugin": "^0.13.1", "hard-source-webpack-plugin": "^0.13.1",
"jest": "^27.5.1",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.13.1", "node-sass": "^4.13.1",
"optimize-css-assets-webpack-plugin": "^5.0.1", "optimize-css-assets-webpack-plugin": "^5.0.1",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"source-map-loader": "^3.0.0", "source-map-loader": "^3.0.0",
"terser-webpack-plugin": "^2.3.2", "terser-webpack-plugin": "^2.3.2",
"ts-jest": "^27.1.3",
"ts-loader": "8.2.0", "ts-loader": "8.2.0",
"typescript": "^4.5.2", "typescript": "^4.5.2",
"webpack": "^4.41.5", "webpack": "^4.41.5",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

View File

@ -1,3 +0,0 @@
## Implementation Details
Write info on what we'd use, or just start adding in the tests and remove this file.

View File

@ -30,9 +30,7 @@ config :phoenix, :json_library, Jason
config :ueberauth, Ueberauth, config :ueberauth, Ueberauth,
providers: [ providers: [
github: github: {Ueberauth.Strategy.Github, [default_scope: "user:email", allow_private_emails: true]}
{Ueberauth.Strategy.Github, [default_scope: "user:email", allow_private_emails: true]},
google: {Ueberauth.Strategy.Google, [default_scope: "email"]}
] ]
config :waffle, config :waffle,

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: "intended.link", port: 80], url: [host: "example.com", 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,7 +45,8 @@ 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 :my_app, EntenduWeb.Endpoint, force_ssl: [rewrite_on: [:x_forwarded_proto]] # config :entendu, EntenduWeb.Endpoint,
# 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

@ -34,10 +34,6 @@ config :ueberauth, Ueberauth.Strategy.Github.OAuth,
client_id: System.get_env("GH_OAUTH_ID"), client_id: System.get_env("GH_OAUTH_ID"),
client_secret: System.get_env("GH_OAUTH_SECRET") client_secret: System.get_env("GH_OAUTH_SECRET")
config :ueberauth, Ueberauth.Strategy.Google.OAuth,
client_id: System.get_env("GOOGLE_OAUTH_ID"),
client_secret: System.get_env("GOOGLE_OAUTH_SECRET")
# ## Using releases (Elixir v1.9+) # ## Using releases (Elixir v1.9+)
# #
# If you are doing OTP releases, you need to instruct Phoenix # If you are doing OTP releases, you need to instruct Phoenix

View File

@ -6,6 +6,7 @@ defmodule Entendu.UserFromAuth do
require Jason require Jason
alias Ueberauth.Auth alias Ueberauth.Auth
alias Entendu.Links.Link
def find_or_create(%Auth{} = auth) do def find_or_create(%Auth{} = auth) do
{:ok, basic_info(auth)} {:ok, basic_info(auth)}
@ -70,22 +71,7 @@ defmodule Entendu.UserFromAuth do
do: email_matches?(recipient, emails) || username_matches?(recipient, username) do: email_matches?(recipient, emails) || username_matches?(recipient, username)
defp email_matches?(recipient, emails), defp email_matches?(recipient, emails),
do: do: emails |> Enum.any?(&(&1["verified"] == true and &1["email"] == recipient))
emails
|> Enum.filter(&only_verified_emails/1)
|> Enum.map(&retrieve_email/1)
|> Enum.any?(&(&1 == recipient))
# Github lists unverified emails and need to be filtered out
defp only_verified_emails(%{"verified" => is_verified}), do: is_verified
defp only_verified_emails(_), do: true
defp retrieve_email(%{"email" => email}), do: email
defp retrieve_email(email), do: email
defp username_matches?(_recipient, nil), do: false
defp username_matches?(recipient, username), do: String.trim(username) === recipient defp username_matches?(recipient, username), do: String.trim(username) === recipient
end end

View File

@ -8,6 +8,8 @@ defmodule EntenduWeb.AuthController do
plug Ueberauth plug Ueberauth
alias Entendu.UserFromAuth alias Entendu.UserFromAuth
alias EntenduWeb.LinkView
alias Entendu.EncryptedLink
def delete(conn, _params) do def delete(conn, _params) do
conn conn
@ -25,8 +27,11 @@ defmodule EntenduWeb.AuthController do
def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
link = get_session(conn, :intended_link) link = get_session(conn, :intended_link)
with %{id: link_id} <- link, with %{id: link_id, recipient: recipient} <- link,
{:ok, user} <- UserFromAuth.find_or_create(auth) do {:ok, user} <- UserFromAuth.find_or_create(auth),
true <- UserFromAuth.can_access?(recipient, user) do
# TODO: send over encrypted data that the frontend can decrypt
conn conn
|> put_session(:current_user, user) |> put_session(:current_user, user)
|> configure_session(renew: true) |> configure_session(renew: true)
@ -37,6 +42,11 @@ 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

@ -9,6 +9,8 @@ defmodule EntenduWeb.LinkController do
alias Entendu.Links alias Entendu.Links
alias Links.Link alias Links.Link
alias EntenduWeb.FallbackController alias EntenduWeb.FallbackController
alias Entendu.EncryptedLink
alias Entendu.UserFromAuth
alias EntenduWeb.Plugs.AuthorizeLink alias EntenduWeb.Plugs.AuthorizeLink
plug AuthorizeLink when action in [:text, :file] plug AuthorizeLink when action in [:text, :file]
@ -16,8 +18,7 @@ defmodule EntenduWeb.LinkController do
action_fallback(FallbackController) action_fallback(FallbackController)
def just_page(conn, _params) do def just_page(conn, _params) do
conn render(conn, "just.html")
|> render("just.html")
end end
def just(conn, params) do def just(conn, params) do
@ -35,7 +36,6 @@ 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,17 +45,28 @@ defmodule EntenduWeb.LinkController do
end end
def auth_page(conn, %{"id" => link_id}) do def auth_page(conn, %{"id" => link_id}) do
with %Link{id: id, service: service, recipient: recipient} <- Links.get_link(link_id) do with %Link{service: service, recipient: recipient} = link <- Links.get_link(link_id) do
conn conn
|> put_session(:intended_link, %{id: id, service: service, recipient: recipient}) |> put_session(:intended_link, link)
|> render("auth.html", %{intended_link: %{service: service, recipient: recipient}}) |> render("auth.html", %{intended_link: %{service: service, recipient: recipient}})
end end
end end
def authorized_link(conn, %{"id" => link_id}) do def text(conn, %{"id" => link_id}) do
with %Link{} = link <- Links.get_link(link_id) do with user = get_session(conn, :current_user),
conn %Link{recipient: recipient} = link <- Links.get_link(link_id),
|> render("show_authorized.json", %{link: link}) true <- UserFromAuth.can_access?(recipient, user) do
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,13 +6,6 @@ defmodule EntenduWeb.PageController do
use EntenduWeb, :controller use EntenduWeb, :controller
def index(conn, _params) do def index(conn, _params) do
conn render(conn, "index.html", current_user: get_session(conn, :current_user))
|> clear_session()
|> render("index.html")
end
def privacy(conn, _params) do
conn
|> render("privacy_policy.html")
end end
end end

View File

@ -2,30 +2,29 @@ defmodule EntenduWeb.Plugs.AuthorizeLink do
import Plug.Conn import Plug.Conn
use EntenduWeb, :controller use EntenduWeb, :controller
alias Entendu.Repo
alias Entendu.UserFromAuth alias Entendu.UserFromAuth
alias Entendu.Links alias Entendu.Links
alias Entendu.Links.Link alias Entendu.Links.Link
alias EntenduWeb.FallbackController
alias EntenduWeb.ErrorView alias EntenduWeb.ErrorView
def init(_params) do def init(_params) do
end end
defp get_link_id(%{params: %{"id" => link_id}}), do: link_id def call(conn, params) do
%{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
conn conn
|> put_status(403) |> put_status(403)
|> put_view(ErrorView) |> put_view(EntenduWeb.ErrorView)
|> render("error_code.json", message: "Unauthorized", code: 403) |> render("error_code.json", message: "Unauthorized", code: 403)
|> halt |> halt
else else
with %Link{recipient: recipient} = link <- Links.get_link(link_id), with {:ok, user} <- get_user_from_path(conn),
%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)
@ -33,24 +32,39 @@ defmodule EntenduWeb.Plugs.AuthorizeLink do
nil -> nil ->
conn conn
|> put_status(404) |> put_status(404)
|> put_view(ErrorView) |> put_view(EntenduWeb.ErrorView)
|> render("error_code.json", message: "Link could not be found", code: 404) |> render("error_code.json", message: "Link could not be found", code: 404)
|> halt |> halt
false -> false ->
conn conn
|> put_status(403) |> put_status(403)
|> put_view(ErrorView) |> put_view(EntenduWeb.ErrorView)
|> render("error_code.json", message: "Unauthorized", code: 403) |> render("error_code.json", message: "Unauthorized", code: 403)
|> halt |> halt
{:error, reason} -> {:error, reason} ->
conn conn
|> put_status(422) |> put_status(422)
|> put_view(ErrorView) |> put_view(EntenduWeb.ErrorView)
|> render("error_code.json", message: reason, code: 422) |> render("error_code.json", message: reason, code: 422)
|> halt |> halt
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,13 +16,9 @@ defmodule EntenduWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
pipeline :authorized_files do pipeline :authorized_links 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
@ -35,28 +31,23 @@ 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 "/privacy-policy", PageController, :privacy 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_files] pipe_through [:browser, :authorized_links]
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,6 +1,5 @@
<main role="main"> <main role="main">
<a href="/"> <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<img src="<%= Routes.static_path(@conn, "/images/logo.png") %>" class="logo" /> <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
</a>
<%= @inner_content %> <%= @inner_content %>
</main> </main>

View File

@ -4,24 +4,12 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/> <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<!-- Essential META Tags -->
<meta property="og:title" content="Intended Link">
<meta property="og:type" content="website" />
<meta property="og:image" content="https://intended.link/images/thumbnail.jpg">
<meta property="og:url" content="https://intended.link">
<meta name="twitter:card" content="summary_large_image">
<!-- Non-Essential, But Recommended -->
<meta property="og:description" content="Securely send private messages to social media accounts.">
<meta property="og:site_name" content="Intended Link">
<meta name="twitter:image:alt" content="Preview of splash page">
<%= csrf_meta_tag() %> <%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "Intended Link", suffix: "" %> <%= live_title_tag assigns[:page_title] || "Entendu", suffix: " · Phoenix Framework" %>
<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: linear-gradient(180deg,#060b2e 0%,#051745 100%); min-height: 100%;"> <body style="background: #060b2e;">
<%= @inner_content %> <%= @inner_content %>
</body> </body>
</html> </html>

View File

@ -3,6 +3,5 @@
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,5 +1,46 @@
<section> <section>
<%= react_component("Components.SplashPage", %{ error: get_flash(@conn, :error) }) %> <%= react_component("Components.SplashPage") %>
<%= 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

@ -1,3 +0,0 @@
<section>
<%= react_component("Components.PrivacyPolicyPage") %>
</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
"priv/uploads/links/#{scope.id}" "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

View File

@ -50,7 +50,6 @@ defmodule Entendu.MixProject do
{:libcluster, "~> 3.2"}, {:libcluster, "~> 3.2"},
{:ueberauth, "~> 0.7.0"}, {:ueberauth, "~> 0.7.0"},
{:ueberauth_github, "~> 0.8.1"}, {:ueberauth_github, "~> 0.8.1"},
{:ueberauth_google, "~> 0.10.1"},
{:react_phoenix, "~> 1.3"}, {:react_phoenix, "~> 1.3"},
{:params, "~> 2.2"}, {:params, "~> 2.2"},
{:waffle, "~> 1.1"}, {:waffle, "~> 1.1"},

View File

@ -41,7 +41,6 @@
"telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"}, "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
"ueberauth": {:hex, :ueberauth, "0.7.0", "9c44f41798b5fa27f872561b6f7d2bb0f10f03fdd22b90f454232d7b087f4b75", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2efad9022e949834f16cc52cd935165049d81fa9e925690f91035c2e4b58d905"}, "ueberauth": {:hex, :ueberauth, "0.7.0", "9c44f41798b5fa27f872561b6f7d2bb0f10f03fdd22b90f454232d7b087f4b75", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2efad9022e949834f16cc52cd935165049d81fa9e925690f91035c2e4b58d905"},
"ueberauth_github": {:hex, :ueberauth_github, "0.8.1", "0be487b5afc29bc805fa5e31636f37c8f09d5159ef73fc08c4c7a98c9cfe2c18", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "143d6130b945ea9bdbd0ef94987f40788f1d7e8090decbfc0722773155e7a74a"}, "ueberauth_github": {:hex, :ueberauth_github, "0.8.1", "0be487b5afc29bc805fa5e31636f37c8f09d5159ef73fc08c4c7a98c9cfe2c18", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "143d6130b945ea9bdbd0ef94987f40788f1d7e8090decbfc0722773155e7a74a"},
"ueberauth_google": {:hex, :ueberauth_google, "0.10.1", "db7bd2d99d2ff38e7449042a08d9560741b0dcaf1c31191729b97188b025465e", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "b799f547d279bb836e1f7039fc9fbb3a9d008a695e2a25bd06bffe591a168ba1"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"waffle": {:hex, :waffle, "1.1.5", "11b8b41c9dc46a21c8e1e619e1e9048d18d166b57b33d1fada8e11fcd4e678b3", [:mix], [{:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.1", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "68e6f92b457b13c71e33cc23f7abb60446a01515dc6618b7d493d8cd466b1f39"}, "waffle": {:hex, :waffle, "1.1.5", "11b8b41c9dc46a21c8e1e619e1e9048d18d166b57b33d1fada8e11fcd4e678b3", [:mix], [{:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.1", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "68e6f92b457b13c71e33cc23f7abb60446a01515dc6618b7d493d8cd466b1f39"},
"waffle_ecto": {:hex, :waffle_ecto, "0.0.11", "3d9581b3dfc83964ad968ef6bbf31132b5e6959c542a74c49e2a2245a9521048", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:waffle, "~> 1.0", [hex: :waffle, repo: "hexpm", optional: false]}], "hexpm", "626c2832ba94e20840532e609d3af70526d18ff9dfe1b352afb3fbabedb31a7e"}, "waffle_ecto": {:hex, :waffle_ecto, "0.0.11", "3d9581b3dfc83964ad968ef6bbf31132b5e6959c542a74c49e2a2245a9521048", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:waffle, "~> 1.0", [hex: :waffle, repo: "hexpm", optional: false]}], "hexpm", "626c2832ba94e20840532e609d3af70526d18ff9dfe1b352afb3fbabedb31a7e"},