├── public ├── .gitkeep ├── robots.txt ├── assets │ ├── images │ │ ├── webrtc.png │ │ ├── sharedrop-icon.png │ │ ├── sharedrop-icon-128x128.png │ │ ├── sharedrop-icon-1024x1024.png │ │ ├── select-arrow.svg │ │ ├── github.svg │ │ ├── avatars │ │ │ ├── 46.svg │ │ │ ├── 37.svg │ │ │ ├── 36.svg │ │ │ ├── 38.svg │ │ │ ├── 86.svg │ │ │ ├── 64.svg │ │ │ ├── 87.svg │ │ │ ├── 112.svg │ │ │ ├── 109.svg │ │ │ ├── 50.svg │ │ │ ├── 63.svg │ │ │ ├── 23.svg │ │ │ ├── 61.svg │ │ │ ├── 52.svg │ │ │ ├── 40.svg │ │ │ ├── 74.svg │ │ │ ├── 65.svg │ │ │ ├── 66.svg │ │ │ ├── 59.svg │ │ │ ├── 95.svg │ │ │ └── 71.svg │ │ └── sharedrop-light.svg │ └── fonts │ │ └── glyphicons │ │ ├── glyphicons-filetypes-regular.eot │ │ ├── glyphicons-filetypes-regular.ttf │ │ └── glyphicons-filetypes-regular.woff ├── .well-known │ └── brave-rewards-verification.txt └── crossdomain.xml ├── vendor ├── .gitkeep └── ba-tiny-pubsub.min.js ├── tests ├── helpers │ └── .gitkeep ├── unit │ └── .gitkeep ├── integration │ └── .gitkeep ├── test-helper.js └── index.html ├── Procfile ├── Procfile.dev ├── .watchmanconfig ├── app ├── styles │ ├── base │ │ ├── _variables.sass │ │ ├── _element_defaults.sass │ │ ├── _mixins.sass │ │ └── _reset.sass │ ├── app.sass │ ├── layout │ │ ├── _content.sass │ │ ├── _footer.sass │ │ ├── _header.sass │ │ └── _media.sass │ └── modules │ │ ├── _modules.sass │ │ ├── _popover.sass │ │ ├── _modal.sass │ │ └── _users.sass ├── templates │ ├── about-you.hbs │ ├── errors │ │ ├── popovers │ │ │ ├── connection-failed.hbs │ │ │ └── multiple-files.hbs │ │ ├── browser-unsupported.hbs │ │ └── filesystem-unavailable.hbs │ ├── components │ │ ├── modal-dialog.hbs │ │ ├── user-widget.hbs │ │ ├── popover-confirm.hbs │ │ └── peer-widget.hbs │ ├── index.hbs │ ├── about-room.hbs │ ├── application.hbs │ └── about-app.hbs ├── helpers │ └── is-equal.js ├── components │ ├── circular-progress.hbs │ ├── user-widget.js │ ├── modal-dialog.js │ ├── room-url.js │ ├── circular-progress.js │ ├── popover-confirm.js │ ├── file-field.js │ ├── peer-avatar.js │ └── peer-widget.js ├── services │ ├── analytics.js │ ├── room.js │ ├── avatar.js │ └── file.js ├── routes │ ├── error.js │ ├── application.js │ ├── room.js │ └── index.js ├── models │ ├── user.js │ └── peer.js ├── router.js ├── app.js ├── controllers │ ├── application.js │ └── index.js ├── index.html └── initializers │ └── prerequisites.js ├── lib └── google-analytics │ ├── package.json │ └── index.js ├── config ├── optional-features.json ├── targets.js ├── dotenv.js └── environment.js ├── .env.sample ├── .prettierignore ├── Dockerfile ├── .template-lintrc.js ├── .ember-cli ├── prettier.config.js ├── .eslintignore ├── sharedrop.crx ├── .travis.yml ├── .editorconfig ├── .gitignore ├── newrelic.js ├── testem.js ├── LICENSE ├── firebase_rules.json ├── ember-cli-build.js ├── .eslintrc.js ├── server.js ├── README.md └── package.json /public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node server.js 2 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | server: node server.js 2 | web: ember build --watch 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": [ 3 | "tmp", 4 | "dist" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /app/styles/base/_variables.sass: -------------------------------------------------------------------------------- 1 | $font-family: "Helvetica Neue", sans-serif 2 | 3 | $blue: #0088cc 4 | $green: #a4c540 5 | -------------------------------------------------------------------------------- /public/assets/images/webrtc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShareDropio/sharedrop/HEAD/public/assets/images/webrtc.png -------------------------------------------------------------------------------- /app/templates/about-you.hbs: -------------------------------------------------------------------------------- 1 | ShareDrop lets you share files with others. 2 | Other people will see you as {{you.label}}. -------------------------------------------------------------------------------- /app/templates/errors/popovers/connection-failed.hbs: -------------------------------------------------------------------------------- 1 | It was not possible to establish direct connection with the other peer. 2 | -------------------------------------------------------------------------------- /lib/google-analytics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google-analytics", 3 | "keywords": [ 4 | "ember-addon" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /public/assets/images/sharedrop-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShareDropio/sharedrop/HEAD/public/assets/images/sharedrop-icon.png -------------------------------------------------------------------------------- /public/assets/images/sharedrop-icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShareDropio/sharedrop/HEAD/public/assets/images/sharedrop-icon-128x128.png -------------------------------------------------------------------------------- /public/assets/images/sharedrop-icon-1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShareDropio/sharedrop/HEAD/public/assets/images/sharedrop-icon-1024x1024.png -------------------------------------------------------------------------------- /app/helpers/is-equal.js: -------------------------------------------------------------------------------- 1 | import { helper as buildHelper } from '@ember/component/helper'; 2 | 3 | export default buildHelper(([leftSide, rightSide]) => leftSide === rightSide); 4 | -------------------------------------------------------------------------------- /public/assets/fonts/glyphicons/glyphicons-filetypes-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShareDropio/sharedrop/HEAD/public/assets/fonts/glyphicons/glyphicons-filetypes-regular.eot -------------------------------------------------------------------------------- /public/assets/fonts/glyphicons/glyphicons-filetypes-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShareDropio/sharedrop/HEAD/public/assets/fonts/glyphicons/glyphicons-filetypes-regular.ttf -------------------------------------------------------------------------------- /public/assets/fonts/glyphicons/glyphicons-filetypes-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShareDropio/sharedrop/HEAD/public/assets/fonts/glyphicons/glyphicons-filetypes-regular.woff -------------------------------------------------------------------------------- /app/components/circular-progress.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/templates/errors/popovers/multiple-files.hbs: -------------------------------------------------------------------------------- 1 | The files you have selected exceed the maximum allowed size of 200MB 2 |

3 | TIP: You can send single files without size restriction 4 | -------------------------------------------------------------------------------- /app/components/user-widget.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | 3 | export default Component.extend({ 4 | classNames: ['peer'], 5 | classNameBindings: ['peer.peer.state'], 6 | }); 7 | -------------------------------------------------------------------------------- /config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": true, 5 | "template-only-glimmer-components": true 6 | } 7 | -------------------------------------------------------------------------------- /public/.well-known/brave-rewards-verification.txt: -------------------------------------------------------------------------------- 1 | This is a Brave Rewards publisher verification file. 2 | 3 | Domain: sharedrop.io 4 | Token: d463c371edd92de3a09b0ccf1ce8143b9ee7f8c5611734f7ab58e2b67934b4bb 5 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | PORT=8000 2 | SECRET=qwerty 3 | FIREBASE_URL=https://your-firebase.firebaseio.com 4 | FIREBASE_SECRET=qwerty 5 | NEW_RELIC_ENABLED=false 6 | NEW_RELIC_LICENSE_KEY=qwerty 7 | GOOGLE_ANALYTICS_ID=UA-XXXX-Y 8 | -------------------------------------------------------------------------------- /app/services/analytics.js: -------------------------------------------------------------------------------- 1 | export default { 2 | trackEvent(name, parameters) { 3 | if (window.gtag && typeof window.gtag === 'function') { 4 | window.gtag('event', name, parameters); 5 | } 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | ########## 2 | # Common 3 | ########## 4 | 5 | # Duh 6 | .git/ 7 | 8 | # Third party 9 | /node_modules/ 10 | /vendor/ 11 | 12 | # Build products 13 | /dist/ 14 | /tmp/ 15 | /coverage/ 16 | /.sass-cache/ 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-buster 2 | RUN mkdir -p /srv/app 3 | WORKDIR /srv/app 4 | COPY package.json yarn.lock ./ 5 | RUN yarn --frozen-lockfile --non-interactive 6 | 7 | COPY . /srv/app 8 | EXPOSE 8000 9 | CMD [ "yarn", "develop" ] 10 | -------------------------------------------------------------------------------- /app/templates/errors/browser-unsupported.hbs: -------------------------------------------------------------------------------- 1 |
2 |

We're really sorry, but your browser is not supported.
Please use the latest desktop or Android version of
Chrome, Opera or Firefox.

3 |
4 | -------------------------------------------------------------------------------- /app/templates/components/modal-dialog.hbs: -------------------------------------------------------------------------------- 1 | {{! template-lint-disable no-invalid-interactive }} 2 | 3 | 6 | {{! template-lint-enable no-invalid-interactive }} -------------------------------------------------------------------------------- /config/targets.js: -------------------------------------------------------------------------------- 1 | const browsers = [ 2 | 'last 2 Chrome versions', 3 | 'last 2 Firefox versions', 4 | 'last 2 Safari versions', 5 | 'last 2 iOS versions', 6 | 'last 2 Edge versions', 7 | ]; 8 | 9 | module.exports = { 10 | browsers, 11 | }; 12 | -------------------------------------------------------------------------------- /app/templates/errors/filesystem-unavailable.hbs: -------------------------------------------------------------------------------- 1 |
2 |

Uh oh. Looks like there's some issue and we won't be able
to save your files.

3 |

If you've opened this app in incognito/private window,
try again in a normal one.

4 |
5 | -------------------------------------------------------------------------------- /.template-lintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'octane', 3 | 4 | // TODO: enable these 5 | rules: { 6 | 'no-action': false, 7 | 'no-curly-component-invocation': false, 8 | 'no-implicit-this': false, 9 | 'no-partial': false, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /config/dotenv.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | return { 3 | clientAllowedKeys: ['FIREBASE_URL'], 4 | // Fail build when there is missing any of clientAllowedKeys environment variables. 5 | // By default false. 6 | failOnMissingKey: false, 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | /* eslint */ 2 | import { setApplication } from '@ember/test-helpers'; 3 | import { start } from 'ember-qunit'; 4 | import Application from 'sharedrop/app'; 5 | import config from 'sharedrop/config/environment'; 6 | 7 | setApplication(Application.create(config.APP)); 8 | 9 | start(); 10 | -------------------------------------------------------------------------------- /public/assets/images/select-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /app/components/modal-dialog.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | 3 | export default Component.extend({ 4 | actions: { 5 | close() { 6 | // This sends an action to application route. 7 | // eslint-disable-next-line ember/closure-actions 8 | return this.onClose(); 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // https://prettier.io/docs/en/options.html 2 | 3 | module.exports = { 4 | printWidth: 80, 5 | tabWidth: 2, 6 | useTabs: false, 7 | semi: true, 8 | singleQuote: true, 9 | trailingComma: 'all', 10 | bracketSpacing: true, 11 | jsxBracketSameLine: false, 12 | arrowParens: 'always', 13 | }; 14 | -------------------------------------------------------------------------------- /app/templates/components/user-widget.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{users.label}} 3 |
4 |
5 |
You
6 |
7 | {{user.label}} 8 |
9 |
10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | 17 | # ember-try 18 | /.node_modules.ember-try/ 19 | /bower.json.ember-try 20 | /package.json.ember-try 21 | -------------------------------------------------------------------------------- /vendor/ba-tiny-pubsub.min.js: -------------------------------------------------------------------------------- 1 | /*! Tiny Pub/Sub - v0.7.0 - 2013-01-29 2 | * https://github.com/cowboy/jquery-tiny-pubsub 3 | * Copyright (c) 2013 "Cowboy" Ben Alman; Licensed MIT */ 4 | (function(n){var u=n({});n.subscribe=function(){u.on.apply(u,arguments)},n.unsubscribe=function(){u.off.apply(u,arguments)},n.publish=function(){u.trigger.apply(u,arguments)}})(jQuery); -------------------------------------------------------------------------------- /app/routes/error.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({ 4 | renderTemplate(controller, error) { 5 | const errors = ['browser-unsupported', 'filesystem-unavailable']; 6 | const name = `errors/${error.message}`; 7 | 8 | if (errors.indexOf(error.message) !== -1) { 9 | this.render(name); 10 | } 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /app/styles/app.sass: -------------------------------------------------------------------------------- 1 | @import base/reset 2 | 3 | @import base/variables 4 | @import base/mixins 5 | @import base/element_defaults 6 | @import base/glyphicons_filetypes 7 | 8 | @import modules/modules 9 | @import modules/modal 10 | @import modules/users 11 | @import modules/popover 12 | 13 | @import layout/header 14 | @import layout/content 15 | @import layout/footer 16 | @import layout/media 17 | -------------------------------------------------------------------------------- /sharedrop.crx: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ShareDrop", 3 | "description": "Simple file sharing", 4 | "version": "1", 5 | "app": { 6 | "urls": [ 7 | "https:/www.sharedrop.io/" 8 | ], 9 | "launch": { 10 | "web_url": "https:/www.sharedrop.io/" 11 | } 12 | }, 13 | "icons": { 14 | "128": "sharedrop-icon-128.png" 15 | }, 16 | "permissions": [ 17 | "notifications" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /app/models/user.js: -------------------------------------------------------------------------------- 1 | import Peer from './peer'; 2 | 3 | const User = Peer.extend({ 4 | serialize() { 5 | const data = { 6 | uuid: this.uuid, 7 | public_ip: this.public_ip, 8 | label: this.label, 9 | avatarUrl: this.avatarUrl, 10 | peer: { 11 | id: this.get('peer.id'), 12 | }, 13 | }; 14 | 15 | return data; 16 | }, 17 | }); 18 | 19 | export default User; 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | node_js: 4 | - "12" 5 | 6 | dist: xenial 7 | 8 | addons: 9 | chrome: stable 10 | 11 | cache: 12 | yarn: true 13 | 14 | env: 15 | global: 16 | # See https://git.io/vdao3 for details. 17 | - JOBS=1 18 | 19 | before_install: 20 | - curl -o- -L https://yarnpkg.com/install.sh | bash 21 | - export PATH=$HOME/.yarn/bin:$PATH 22 | 23 | script: 24 | - yarn test 25 | -------------------------------------------------------------------------------- /app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from 'sharedrop/config/environment'; 3 | 4 | export default class Router extends EmberRouter { 5 | location = config.locationType; 6 | 7 | rootURL = config.rootURL; 8 | } 9 | 10 | // eslint-disable-next-line array-callback-return 11 | Router.map(function () { 12 | this.route('room', { 13 | path: '/rooms/:room_id', 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist/ 5 | /tmp/ 6 | 7 | # dependencies 8 | /bower_components/ 9 | /node_modules/ 10 | 11 | # misc 12 | /.env* 13 | /.pnp* 14 | /.sass-cache 15 | /connect.lock 16 | /coverage/ 17 | /libpeerconnection.log 18 | /npm-debug.log* 19 | /testem.log 20 | /yarn-error.log 21 | 22 | # ember-try 23 | /.node_modules.ember-try/ 24 | /bower.json.ember-try 25 | /package.json.ember-try 26 | -------------------------------------------------------------------------------- /app/styles/base/_element_defaults.sass: -------------------------------------------------------------------------------- 1 | *, *::before, *::after 2 | box-sizing: border-box 3 | 4 | html 5 | height: 100% 6 | font-family: $font-family 7 | font-size: 10px 8 | 9 | body 10 | height: 100% 11 | 12 | a 13 | text-decoration: none 14 | 15 | b, strong 16 | font-weight: bold 17 | 18 | input, select 19 | font-family: inherit 20 | padding: 1rem 21 | border: 1px solid #ccc 22 | width: 100% 23 | border-radius: .3rem 24 | font-size: 1.4rem 25 | 26 | .invisible 27 | height: 0 28 | width: 0 29 | opacity: 0 30 | -------------------------------------------------------------------------------- /app/routes/application.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({ 4 | setupController(controller) { 5 | controller.set('currentRoute', this); 6 | }, 7 | 8 | actions: { 9 | openModal(modalName) { 10 | return this.render(modalName, { 11 | outlet: 'modal', 12 | into: 'application', 13 | }); 14 | }, 15 | 16 | closeModal() { 17 | return this.disconnectOutlet({ 18 | outlet: 'modal', 19 | parentView: 'application', 20 | }); 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /app/components/room-url.js: -------------------------------------------------------------------------------- 1 | import TextField from '@ember/component/text-field'; 2 | import $ from 'jquery'; 3 | 4 | export default TextField.extend({ 5 | classNames: ['room-url'], 6 | 7 | didInsertElement() { 8 | $(this.element).focus().select(); 9 | }, 10 | 11 | copyValueToClipboard() { 12 | if (window.ClipboardEvent) { 13 | const pasteEvent = new window.ClipboardEvent('paste', { 14 | dataType: 'text/plain', 15 | data: this.element.value, 16 | }); 17 | document.dispatchEvent(pasteEvent); 18 | } 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /app/styles/base/_mixins.sass: -------------------------------------------------------------------------------- 1 | =ellipsis 2 | overflow: hidden 3 | white-space: nowrap 4 | text-overflow: ellipsis 5 | 6 | =button_reset 7 | margin: 0 8 | padding: 0 9 | display: inline-block 10 | border: none 11 | background: none 12 | outline: none 13 | width: auto 14 | cursor: pointer 15 | font-family: inherit 16 | 17 | =shape($size, $shape) 18 | display: inline-block 19 | width: $size 20 | height: $size 21 | line-height: $size 22 | text-align: center 23 | vertical-align: middle 24 | @if $shape == circle 25 | border-radius: 50% 26 | -------------------------------------------------------------------------------- /app/styles/layout/_content.sass: -------------------------------------------------------------------------------- 1 | .l-content 2 | position: relative 3 | height: 100vh 4 | min-height: 600px 5 | 6 | .visually-hidden 7 | clip: rect(0 0 0 0) 8 | clip-path: inset(50%) 9 | height: 1px 10 | overflow: hidden 11 | position: absolute 12 | white-space: nowrap 13 | width: 1px 14 | 15 | .ribbon 16 | position: fixed 17 | left: -80px 18 | bottom: 0px 19 | width: 300px 20 | height: 64px 21 | transform: rotate(45deg) 22 | z-index: 999 23 | background: linear-gradient(-180deg, rgb(0, 91, 187) 50%, rgb(255, 213, 0) 50%) 24 | opacity: 0.8 25 | -------------------------------------------------------------------------------- /app/templates/components/popover-confirm.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 | 7 |

{{yield}}

8 |
9 | 10 |
11 | {{#if cancelButtonLabel}} 12 | 13 | {{/if}} 14 | 15 | {{#if confirmButtonLabel}} 16 | 17 | {{/if}} 18 |
19 |
20 | 21 | -------------------------------------------------------------------------------- /newrelic.js: -------------------------------------------------------------------------------- 1 | /** 2 | * New Relic agent configuration. 3 | * 4 | * See lib/config.defaults.js in the agent distribution for a more complete 5 | * description of configuration variables and their potential values. 6 | */ 7 | exports.config = { 8 | /** 9 | * Array of application names. 10 | */ 11 | app_name: ['ShareDrop'], 12 | 13 | logging: { 14 | /** 15 | * Level at which to log. 'trace' is most useful to New Relic when diagnosing 16 | * issues with the agent, 'info' and higher will impose the least overhead on 17 | * production applications. 18 | */ 19 | level: 'info', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /public/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | test_page: 'tests/index.html?hidepassed', 3 | disable_watching: true, 4 | launch_in_ci: ['Chrome'], 5 | launch_in_dev: ['Chrome'], 6 | browser_start_timeout: 120, 7 | browser_args: { 8 | Chrome: { 9 | ci: [ 10 | // --no-sandbox is needed when running Chrome inside a container 11 | process.env.CI ? '--no-sandbox' : null, 12 | '--headless', 13 | '--disable-dev-shm-usage', 14 | '--disable-software-rasterizer', 15 | '--mute-audio', 16 | '--remote-debugging-port=0', 17 | '--window-size=1440,900', 18 | ].filter(Boolean), 19 | }, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/google-analytics/index.js: -------------------------------------------------------------------------------- 1 | const { name } = require('./package'); 2 | 3 | module.exports = { 4 | name, 5 | 6 | isDevelopingAddon() { 7 | return true; 8 | }, 9 | 10 | contentFor(type, config) { 11 | const id = config.googleAnalyticsId; 12 | 13 | if (type === 'head' && id) { 14 | return ` 15 | 16 | 22 | `; 23 | } 24 | 25 | return ''; 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import Resolver from 'ember-resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from 'sharedrop/config/environment'; 5 | import * as Sentry from '@sentry/browser'; 6 | import { Ember as EmberIntegration } from '@sentry/integrations'; 7 | 8 | Sentry.init({ 9 | dsn: 10 | 'https://ba1292a9c759401dbbda4272f183408d@o432021.ingest.sentry.io/5384091', 11 | integrations: [new EmberIntegration()], 12 | }); 13 | 14 | export default class App extends Application { 15 | modulePrefix = config.modulePrefix; 16 | 17 | podModulePrefix = config.podModulePrefix; 18 | 19 | Resolver = Resolver; 20 | } 21 | 22 | loadInitializers(App, config.modulePrefix); 23 | -------------------------------------------------------------------------------- /app/components/circular-progress.js: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import { htmlSafe } from '@ember/template'; 3 | 4 | const COLORS = { 5 | blue: '0, 136, 204', 6 | orange: '197, 197, 51', 7 | }; 8 | 9 | export default class CircularProgress extends Component { 10 | constructor(owner, args) { 11 | super(owner, args); 12 | 13 | const rgb = COLORS[args.color]; 14 | this.style = htmlSafe(`fill: rgba(${rgb}, .5)`); 15 | } 16 | 17 | get path() { 18 | const π = Math.PI; 19 | const α = this.args.value * 360; 20 | const r = (α * π) / 180; 21 | const mid = α > 180 ? 1 : 0; 22 | const x = Math.sin(r) * 38; 23 | const y = Math.cos(r) * -38; 24 | 25 | return `M 0 0 v -38 A 38 38 1 ${mid} 1 ${x} ${y} z`; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/components/popover-confirm.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { computed } from '@ember/object'; 3 | 4 | export default Component.extend({ 5 | classNames: ['popover-confirm'], 6 | iconClass: computed('filename', function () { 7 | const { filename } = this; 8 | 9 | if (filename) { 10 | const regex = /\.([0-9a-z]+)$/i; 11 | const match = filename.match(regex); 12 | const extension = match && match[1]; 13 | 14 | if (extension) { 15 | return `glyphicon-${extension.toLowerCase()}`; 16 | } 17 | } 18 | 19 | return undefined; 20 | }), 21 | 22 | actions: { 23 | confirm() { 24 | this.onConfirm(); 25 | }, 26 | 27 | cancel() { 28 | this.onCancel(); 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /app/components/file-field.js: -------------------------------------------------------------------------------- 1 | import TextField from '@ember/component/text-field'; 2 | import $ from 'jquery'; 3 | 4 | export default TextField.extend({ 5 | type: 'file', 6 | classNames: ['invisible'], 7 | 8 | click(event) { 9 | event.stopPropagation(); 10 | }, 11 | 12 | change(event) { 13 | const input = event.target; 14 | const { files } = input; 15 | this.onChange({ files }); 16 | this.reset(); 17 | }, 18 | 19 | // Hackish way to reset file input when sender cancels file transfer, 20 | // so if sender wants later to send the same file again, 21 | // the 'change' event is triggered correctly. 22 | reset() { 23 | const field = $(this.element); 24 | field.wrap('
').closest('form').get(0).reset(); 25 | field.unwrap(); 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /app/templates/index.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{#each model as |peer|}} 4 | {{peer-widget peer=peer hasCustomRoomName=hasCustomRoomName webrtc=webrtc}} 5 | {{/each}} 6 |
7 | 8 | {{#if you.uuid}} 9 |
10 | {{user-widget user=you}} 11 |
12 | {{/if}} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | -------------------------------------------------------------------------------- /app/styles/layout/_footer.sass: -------------------------------------------------------------------------------- 1 | .l-footer 2 | position: fixed 3 | z-index: 200 4 | bottom: 0 5 | left: 0 6 | right: 0 7 | background-color: rgba(white,.6) 8 | text-align: center 9 | color: #b0b0b0 10 | a 11 | opacity: .6 12 | transition: opacity .2s linear 13 | &:hover 14 | opacity: 1 15 | > span 16 | display: none 17 | 18 | .about 19 | display: inline-block 20 | font-size: 1.1rem 21 | line-height: 1.4 22 | margin-top: .2em 23 | padding: 0 10px 24 | 25 | .logos 26 | display: flex 27 | align-items: center 28 | justify-content: space-between 29 | padding: 10px 30 | 31 | .left, 32 | .right 33 | display: flex 34 | 35 | .github 36 | width: 20px 37 | height: 20px 38 | background: transparent url("../assets/images/github.svg") no-repeat center 39 | 40 | .twitter 41 | width: 80px 42 | height: 20px 43 | margin-left: 8px 44 | 45 | .donate 46 | img 47 | height: 20px 48 | -------------------------------------------------------------------------------- /app/controllers/application.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | import { inject as service } from '@ember/service'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | import User from '../models/user'; 6 | 7 | export default Controller.extend({ 8 | avatarService: service('avatar'), 9 | 10 | init(...args) { 11 | this._super(args); 12 | 13 | const id = window.Sharedrop.userId; 14 | const ip = window.Sharedrop.publicIp; 15 | const avatar = this.avatarService.get(); 16 | const you = User.create({ 17 | uuid: id, 18 | public_ip: ip, 19 | avatarUrl: avatar.url, 20 | label: avatar.label, 21 | }); 22 | 23 | you.set('peer.id', id); 24 | this.set('you', you); 25 | }, 26 | 27 | actions: { 28 | redirect() { 29 | const uuid = uuidv4(); 30 | const key = `show-instructions-for-room-${uuid}`; 31 | 32 | sessionStorage.setItem(key, 'yup'); 33 | this.transitionToRoute('room', uuid); 34 | }, 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /app/templates/about-room.hbs: -------------------------------------------------------------------------------- 1 | {{#modal-dialog onClose=(action "closeModal" target=currentRoute)}} 2 | 3 |

Share files between devices in different networks

4 | 5 |

6 | Copy provided address and send it to the other person... 7 |

8 | 9 |

10 | {{room-url value=currentUrl readonly="readonly" style="display: block; margin: auto;"}} 11 |

12 | 13 |

14 | Or you can scan it on the other device. 15 |

16 | 17 | 18 |

19 | {{qr-code text=currentUrl}} 20 |

21 | 22 |

23 | Once the other person open this page in a browser, you'll see each other's avatars. 24 |

25 | 26 |

27 | Drag and drop a file directly on other person's avatar or click the avatar and select the file you want to send. The file transfer will start once the recipient accepts the file. 28 |

29 | 30 |
31 | 32 |
33 | {{/modal-dialog}} 34 | -------------------------------------------------------------------------------- /public/assets/images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/routes/room.js: -------------------------------------------------------------------------------- 1 | import IndexRoute from './index'; 2 | 3 | export default IndexRoute.extend({ 4 | controllerName: 'index', 5 | 6 | model(params) { 7 | // Get room name from params 8 | return params.room_id; 9 | }, 10 | 11 | afterModel(model, transition) { 12 | transition.then((route) => { 13 | route 14 | .controllerFor('application') 15 | .set('currentUrl', window.location.href); 16 | }); 17 | }, 18 | 19 | setupController(ctrl, model) { 20 | // Call this method on "index" controller 21 | this._super(ctrl, model); 22 | 23 | ctrl.set('hasCustomRoomName', true); 24 | }, 25 | 26 | renderTemplate(ctrl) { 27 | this.render('index'); 28 | 29 | this.render('about_you', { 30 | into: 'application', 31 | outlet: 'about_you', 32 | }); 33 | 34 | const room = ctrl.get('room').name; 35 | const key = `show-instructions-for-room-${room}`; 36 | 37 | if (sessionStorage.getItem(key)) { 38 | this.send('openModal', 'about_room'); 39 | sessionStorage.removeItem(key); 40 | } 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /app/styles/base/_reset.sass: -------------------------------------------------------------------------------- 1 | a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video 2 | margin: 0 3 | padding: 0 4 | border: 0 5 | font: inherit 6 | font-size: 100% 7 | vertical-align: baseline 8 | 9 | html 10 | line-height: 1 11 | 12 | ol,ul 13 | list-style: none 14 | 15 | table 16 | border-collapse: collapse 17 | border-spacing: 0 18 | 19 | caption,td,th 20 | text-align: left 21 | font-weight: 400 22 | vertical-align: middle 23 | 24 | blockquote,q 25 | quotes: none 26 | 27 | blockquote:after,blockquote:before,q:after,q:before 28 | content: "" 29 | content: none 30 | 31 | a img 32 | border: 0 33 | 34 | article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary 35 | display: block 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014-2024 Szymon Nowak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /firebase_rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "rooms": { 4 | "$roomid": { 5 | // You can see people in the room only if you are in it as well 6 | ".read": "auth != null && data.child('users').hasChild(auth.id)", 7 | "users": { 8 | "$userid": { 9 | // You can modify only your own info 10 | ".write": "auth != null && $userid == auth.id", 11 | 12 | // Ensure that all required attributes are there 13 | ".validate": "newData.hasChildren(['uuid', 'public_ip', 'peer']) && newData.child('peer').hasChildren(['id'])" 14 | 15 | } 16 | }, 17 | "messages": { 18 | // You can send message to anybody in the room 19 | ".write": "auth != null", 20 | "$userid": { 21 | // You can read only messages sent to you 22 | ".read": "auth != null && $userid == auth.id" 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | EmberCliTest Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{content-for "body-footer"}} 31 | {{content-for "test-body-footer"}} 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/styles/modules/_modules.sass: -------------------------------------------------------------------------------- 1 | .preloader 2 | position: absolute 3 | left: 0 4 | right: 0 5 | top: 0 6 | bottom: 0 7 | margin: auto 8 | width: 324px 9 | height: 56px 10 | background: transparent url("../assets/images/sharedrop.svg") no-repeat center 11 | background-size: 324px 56px 12 | transition: opacity .2s 13 | > span 14 | position: absolute 15 | text-align: center 16 | width: 100% 17 | bottom: -18px 18 | font-size: 1.4rem 19 | 20 | .ember-application 21 | .preloader 22 | opacity: 0 23 | 24 | .error 25 | position: absolute 26 | top: 0 27 | bottom: 0 28 | left: 0 29 | right: 0 30 | margin: auto 31 | width: 50rem 32 | height: 15rem 33 | text-align: center 34 | font-size: 1.8rem 35 | line-height: 1.5em 36 | 37 | .circles 38 | position: absolute 39 | bottom: 0 40 | left: 50% 41 | width: 1140px 42 | margin-left: -570px 43 | height: 700px 44 | z-index: -1 45 | transform-origin: 570px 570px 46 | animation: grow 1.5s ease-out 47 | .circle 48 | stroke-width: .4 49 | fill: rgba(0,0,0,0) 50 | 51 | @keyframes grow 52 | 50% 53 | transform: scale(1.5, 1.5) 54 | opacity: .2 55 | 56 | 51% 57 | transform: scale(0.5, 0.5) 58 | opacity: 0 59 | -------------------------------------------------------------------------------- /app/styles/layout/_header.sass: -------------------------------------------------------------------------------- 1 | .l-header 2 | .navbar 3 | position: fixed 4 | z-index: 10 5 | top: 0 6 | left: 0 7 | width: 100% 8 | height: 60px 9 | background: transparent 10 | color: black 11 | user-select: none 12 | .logo 13 | position: relative 14 | height: 38px 15 | width: 162px 16 | margin: 15px 0 0 15px 17 | background: transparent url("../assets/images/sharedrop.svg") no-repeat left top 18 | .logo-title 19 | display: none 20 | .logo-subtitle 21 | position: absolute 22 | bottom: 0 23 | left: 44px 24 | font-size: 1rem 25 | font-weight: bold 26 | img 27 | vertical-align: top 28 | 29 | .nav 30 | position: absolute 31 | top: 15px 32 | right: 15px 33 | > li 34 | float: left 35 | margin-left: 15px 36 | font-size: 1.4rem 37 | line-height: 30px 38 | a 39 | display: inline-block 40 | transition: opacity .175s linear 41 | color: black 42 | img 43 | vertical-align: middle 44 | &:hover 45 | opacity: .6 46 | 47 | .icon-create-room 48 | font-size: 28px 49 | line-height: 24px 50 | 51 | .icon-help 52 | +shape(18px, circle) 53 | border: 1px solid black 54 | font-size: 1.2rem 55 | opacity: .18 56 | margin-top: -2px 57 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | const EmberApp = require('ember-cli/lib/broccoli/ember-app'); 3 | 4 | module.exports = function (defaults) { 5 | const app = new EmberApp(defaults, { 6 | // Don't include SVG files, because of animal icons being loaded dynamically 7 | fingerprint: { 8 | extensions: ['js', 'css', 'png', 'jpg', 'gif', 'map'], 9 | }, 10 | 11 | // Generate source maps in production as well 12 | sourcemaps: { enabled: true }, 13 | 14 | sassOptions: { extension: 'sass' }, 15 | 16 | SRI: { enabled: false }, 17 | }); 18 | 19 | // Use `app.import` to add additional libraries to the generated 20 | // output files. 21 | // 22 | // If you need to use different assets in different 23 | // environments, specify an object as the first parameter. That 24 | // object's keys should be the environment name and the values 25 | // should be the asset to use in that environment. 26 | // 27 | // If the library that you are including contains AMD or ES6 28 | // modules that you would like to import into your application 29 | // please specify an object with the list of modules as keys 30 | // along with the exports of each module as its value. 31 | 32 | app.import('vendor/ba-tiny-pubsub.min.js'); 33 | app.import('vendor/filer.min.js'); 34 | app.import('vendor/idb.filesystem.min.js'); 35 | app.import('vendor/peer.js'); 36 | 37 | return app.toTree(); 38 | }; 39 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ShareDrop 7 | 11 | 12 | 13 | 14 | 19 | 24 | 25 | {{content-for "head"}} 26 | 27 | 28 | 33 | 34 | {{content-for "head-footer"}} 35 | 36 | 37 | 38 |
Loading...
39 | 40 | {{content-for "body"}} 41 | 42 | 43 | 44 | 45 | 46 | {{content-for "body-footer"}} 47 | 48 | 49 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | module.exports = function (environment) { 2 | const ENV = { 3 | modulePrefix: 'sharedrop', 4 | environment, 5 | rootURL: '/', 6 | locationType: 'auto', 7 | EmberENV: { 8 | FEATURES: { 9 | // Here you can enable experimental features on an ember canary build 10 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 11 | }, 12 | EXTEND_PROTOTYPES: { 13 | // Prevent Ember Data from overriding Date.parse. 14 | Date: false, 15 | }, 16 | }, 17 | 18 | APP: { 19 | // Here you can pass flags/options to your application instance 20 | // when it is created 21 | }, 22 | 23 | FIREBASE_URL: process.env.FIREBASE_URL, 24 | 25 | exportApplicationGlobal: true, 26 | }; 27 | 28 | if (environment === 'development') { 29 | // ENV.APP.LOG_RESOLVER = true; 30 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 31 | // ENV.APP.LOG_TRANSITIONS = true; 32 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 33 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 34 | } 35 | 36 | if (environment === 'test') { 37 | // Testem prefers this... 38 | ENV.locationType = 'none'; 39 | 40 | // keep test console output quieter 41 | ENV.APP.LOG_ACTIVE_GENERATION = false; 42 | ENV.APP.LOG_VIEW_LOOKUPS = false; 43 | 44 | ENV.APP.rootElement = '#ember-testing'; 45 | ENV.APP.autoboot = false; 46 | } 47 | 48 | if (environment === 'production') { 49 | ENV.googleAnalyticsId = 'UA-41889586-2'; 50 | } 51 | 52 | return ENV; 53 | }; 54 | -------------------------------------------------------------------------------- /app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | 18 | 19 | {{outlet}} 20 | {{outlet "modal"}} 21 | 22 | 33 | 34 | We stand with Ukraine! 35 | -------------------------------------------------------------------------------- /app/styles/modules/_popover.sass: -------------------------------------------------------------------------------- 1 | $popover-border-color: #c0c0c0 2 | 3 | .popover 4 | position: absolute 5 | bottom: 100% 6 | left: 50% 7 | transform: translateX(-50%) 8 | z-index: 10 9 | background-color: #fff 10 | border: 1px solid $popover-border-color 11 | padding: 10px 12 | border-radius: 5px 13 | width: 360px 14 | box-shadow: rgba(black,.3) 0 1px 3px 15 | text-align: left 16 | margin-bottom: 5px 17 | &::after 18 | position: absolute 19 | bottom: 0 20 | left: 50% 21 | margin: 0 0 -5px -5px 22 | content: '' 23 | width: 10px 24 | height: 10px 25 | background: inherit 26 | transform: rotate(45deg) 27 | border: 1px solid transparent 28 | border-right-color: $popover-border-color 29 | border-bottom-color: $popover-border-color 30 | 31 | .popover-body 32 | position: relative 33 | padding-left: 60px 34 | p 35 | word-break: break-all 36 | overflow: hidden 37 | font-size: 12px 38 | line-height: 1.4em 39 | margin-bottom: 1em 40 | min-height: 28px 41 | 42 | .popover-icon 43 | position: absolute 44 | left: 0 45 | top: 0 46 | font-size: 50px 47 | 48 | .popover-buttons 49 | text-align: right 50 | 51 | 52 | @media (max-width: 768px) 53 | .popover 54 | left: 0 55 | right: 0 56 | top: 0 57 | bottom: 0 58 | border: none 59 | width: auto 60 | box-shadow: none 61 | border-radius: 0 62 | margin: 0 63 | transform: none 64 | background-color: rgba(#f0f0f0, 0.9) 65 | &::after 66 | display: none 67 | 68 | .popover-buttons 69 | button 70 | font-size: 18px 71 | 72 | -------------------------------------------------------------------------------- /app/styles/modules/_modal.sass: -------------------------------------------------------------------------------- 1 | .modal-overlay 2 | position: fixed 3 | z-index: 300 4 | top: 0 5 | bottom: 0 6 | left: 0 7 | right: 0 8 | background-color: rgba(black,.6) 9 | overflow: auto 10 | 11 | .modal-body 12 | position: absolute 13 | z-index: 301 14 | top: 10% 15 | left: 0 16 | right: 0 17 | margin: auto 18 | background-color: white 19 | width: 580px 20 | padding: 20px 21 | margin-bottom: 40px 22 | border-radius: 5px 23 | h3 24 | font-size: 1.8rem 25 | font-weight: bold 26 | margin-bottom: 0.5em 27 | h4 28 | font-size: 1.5rem 29 | font-weight: bold 30 | margin-bottom: 0.5em 31 | p 32 | font-size: 1.4rem 33 | line-height: 1.4em 34 | margin-bottom: 1.42em 35 | &.note 36 | 37 | font-size: 1.1rem 38 | 39 | a 40 | color: $blue 41 | opacity: .6 42 | &:hover 43 | opacity: 1 44 | 45 | .qr-code 46 | div 47 | padding: 15px 48 | img 49 | margin-left: auto 50 | margin-right: auto 51 | 52 | .actions 53 | text-align: center 54 | button 55 | +button_reset 56 | font-size: 1.6rem 57 | cursor: pointer 58 | border-radius: 5px 59 | background: rgba($blue,.8) 60 | color: #fff 61 | padding: 14px 80px 62 | text-shadow: rgba(black,.3) 0 -1px 0 63 | transition: background .3s 64 | margin-bottom: 20px 65 | &:hover 66 | background: $blue 67 | 68 | .logo 69 | height: 38px 70 | margin-bottom: 20px 71 | background: transparent url("../assets/images/sharedrop.svg") no-repeat left 72 | span 73 | display: none 74 | 75 | .plus-icon 76 | font-weight: bold 77 | font-size: 2rem 78 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const eslintPluginNode = require('eslint-plugin-node'); 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'babel-eslint', 6 | parserOptions: { 7 | ecmaVersion: 2018, 8 | sourceType: 'module', 9 | ecmaFeatures: { 10 | legacyDecorators: true, 11 | }, 12 | }, 13 | plugins: ['ember', 'prettier'], 14 | extends: ['airbnb-base', 'plugin:ember/recommended', 'prettier'], 15 | env: { 16 | browser: true, 17 | }, 18 | rules: { 19 | 'func-names': 'off', 20 | 'no-console': 'off', 21 | 'no-underscore-dangle': 'off', 22 | 23 | 'import/no-extraneous-dependencies': 'off', 24 | 'import/no-unresolved': 'off', 25 | 26 | 'prettier/prettier': 'error', 27 | 28 | // TODO: Enable these 29 | 'ember/no-jquery': 'off', 30 | 'ember/no-observers': 'off', 31 | 'ember/no-get': 'off', 32 | }, 33 | overrides: [ 34 | // node files 35 | { 36 | files: [ 37 | '.eslintrc.js', 38 | '.template-lintrc.js', 39 | 'ember-cli-build.js', 40 | 'testem.js', 41 | 'blueprints/*/index.js', 42 | 'config/**/*.js', 43 | 'lib/*/index.js', 44 | 'server/**/*.js', 45 | ], 46 | parserOptions: { 47 | sourceType: 'script', 48 | }, 49 | env: { 50 | browser: false, 51 | node: true, 52 | }, 53 | plugins: ['node'], 54 | rules: { 55 | ...eslintPluginNode.configs.recommended.rules, 56 | // add your custom rules and overrides for node files here 57 | 58 | // this can be removed once the following is fixed 59 | // https://github.com/mysticatea/eslint-plugin-node/issues/77 60 | 'node/no-unpublished-require': 'off', 61 | }, 62 | }, 63 | ], 64 | }; 65 | -------------------------------------------------------------------------------- /app/models/peer.js: -------------------------------------------------------------------------------- 1 | import EmberObject, { observer } from '@ember/object'; 2 | import Evented, { on } from '@ember/object/evented'; 3 | 4 | export default EmberObject.extend(Evented, { 5 | uuid: null, 6 | label: null, 7 | avatarUrl: null, 8 | public_ip: null, 9 | peer: null, 10 | transfer: null, 11 | 12 | init(...args) { 13 | this._super(args); 14 | 15 | const initialPeerState = EmberObject.create({ 16 | id: null, 17 | connection: null, 18 | // State of data channel connection. Possible states: 19 | // - disconnected 20 | // - connecting 21 | // - connected 22 | state: 'disconnected', 23 | }); 24 | const initialTransferState = EmberObject.create({ 25 | file: null, 26 | info: null, 27 | sendingProgress: 0, 28 | receivingProgress: 0, 29 | }); 30 | 31 | this.set('peer', initialPeerState); 32 | this.set('transfer', initialTransferState); 33 | }, 34 | 35 | // Used to display popovers. Possible states: 36 | // - idle 37 | // - has_selected_file 38 | // - establishing_connection 39 | // - awaiting_response 40 | // - received_file_info 41 | // - declined_file_transfer 42 | // - receiving_file_data 43 | // - sending_file_data 44 | // - error 45 | state: 'idle', 46 | 47 | // Used to display error messages in popovers. Possible codes: 48 | // - multiple_files 49 | errorCode: null, 50 | 51 | stateChanged: on( 52 | 'init', 53 | observer('state', function () { 54 | console.log('Peer:\t State has changed: ', this.state); 55 | 56 | // Automatically clear error code if transitioning to a non-error state 57 | if (this.state !== 'error') { 58 | this.set('errorCode', null); 59 | } 60 | }), 61 | ), 62 | }); 63 | -------------------------------------------------------------------------------- /app/styles/layout/_media.sass: -------------------------------------------------------------------------------- 1 | @media (max-height: 520px) 2 | .modal-body 3 | height: auto 4 | bottom: auto 5 | margin: 15px auto 6 | 7 | @media (max-width: 768px) 8 | .preloader 9 | width: 240px 10 | height: 41px 11 | background-size: 240px 41px 12 | 13 | .modal-body 14 | width: 90% 15 | height: auto 16 | bottom: auto 17 | margin: 15px auto 18 | .note 19 | display: block 20 | 21 | .l-content 22 | padding: 80px 0 115px 23 | min-height: inherit 24 | height: auto 25 | 26 | .user 27 | .peer 28 | position: relative 29 | left: auto !important 30 | bottom: auto !important 31 | height: 106px 32 | width: 100% 33 | padding: 15px 34 | border-bottom: 1px solid #eee 35 | margin: 0 !important 36 | text-align: left 37 | .avatar 38 | z-index: 2 39 | .user-info 40 | z-index: 1 41 | position: absolute 42 | top: 30px 43 | left: 0 44 | padding-left: 170px 45 | width: 100% 46 | .user-email 47 | font-size: 1.4rem 48 | .user-label 49 | font-size: 1.8rem 50 | .user-ip 51 | font-size: 1.2rem 52 | color: #808080 53 | .user-connection-status 54 | top: 0 55 | left: -9.2rem 56 | margin-top: 0 57 | transform: scale(1.75, 1.75) 58 | &.you 59 | .peer 60 | border-bottom: none 61 | 62 | .l-header 63 | .navbar 64 | z-index: 200 65 | background: rgba(white,.7) 66 | .email 67 | display: none 68 | .nav 69 | > li 70 | margin-left: 10px 71 | 72 | .l-footer 73 | padding-top: 10px 74 | 75 | .circles 76 | display: none 77 | 78 | .error 79 | width: auto 80 | font-size: 1.4rem 81 | padding: 0 15px 82 | -------------------------------------------------------------------------------- /app/templates/about-app.hbs: -------------------------------------------------------------------------------- 1 | {{#modal-dialog onClose=(action "closeModal" target=currentRoute)}} 2 | 3 |

What is it?

4 |

5 | ShareDrop is a free, open-source web app that allows you to easily and securely share files directly between devices without uploading them to any server first. 6 |

7 | 8 |

How to use it?

9 |

Sharing files between devices in a local network*

10 |

11 | To send a file to another device in the same local network, open this page (i.e. www.sharedrop.io) on both devices. Drag and drop a file directly on another person's avatar or click the avatar and select the file you want to send. The file transfer will start once the recipient accepts the file. 12 |

13 | 14 |

Sharing files between devices in different networks

15 |

16 | To send a file to another device in a different network, click + button in the upper right corner of the page and follow further instructions. 17 |

18 | 19 |

VPNs

20 |

21 | Sharedrop does not work with VPNs, please deactivate any VPN your device might be using. 22 |

23 | 24 |

Security

25 |

ShareDrop uses a secure and encrypted peer-to-peer connection to transfer information about the file (its name and size) and file data itself. This means that this data is never transfered through any intermediate server but directly between the sender and recipient devices. To achieve this, ShareDrop uses a technology called WebRTC (Web Real-Time Communication), which is provided natively by browsers. You can read more about WebRTC security here.

26 | 27 |

Feedback

28 |

Got a problem with using ShareDrop, suggestion how to improve it or just want to say hi? Send an email to support@sharedrop.io or report an issue on GitHub.

29 | 30 |
31 | 32 |

*Devices need to have the same public IP to see each other.

33 |
34 | {{/modal-dialog}} 35 | -------------------------------------------------------------------------------- /app/initializers/prerequisites.js: -------------------------------------------------------------------------------- 1 | /* jshint -W030 */ 2 | import $ from 'jquery'; 3 | import { Promise } from 'rsvp'; 4 | import config from 'sharedrop/config/environment'; 5 | 6 | import FileSystem from '../services/file'; 7 | import Analytics from '../services/analytics'; 8 | 9 | export function initialize(application) { 10 | function checkWebRTCSupport() { 11 | return new Promise((resolve, reject) => { 12 | // window.util is a part of PeerJS library 13 | if (window.util.supports.sctp) { 14 | resolve(); 15 | } else { 16 | // eslint-disable-next-line prefer-promise-reject-errors 17 | reject('browser-unsupported'); 18 | } 19 | }); 20 | } 21 | 22 | function clearFileSystem() { 23 | return new Promise((resolve, reject) => { 24 | // TODO: change File into a service and require it here 25 | FileSystem.removeAll() 26 | .then(() => { 27 | resolve(); 28 | }) 29 | .catch(() => { 30 | // eslint-disable-next-line prefer-promise-reject-errors 31 | reject('filesystem-unavailable'); 32 | }); 33 | }); 34 | } 35 | 36 | function authenticateToFirebase() { 37 | return new Promise((resolve, reject) => { 38 | const xhr = $.getJSON('/auth'); 39 | xhr.then((data) => { 40 | const ref = new window.Firebase(config.FIREBASE_URL); 41 | // eslint-disable-next-line no-param-reassign 42 | application.ref = ref; 43 | // eslint-disable-next-line no-param-reassign 44 | application.userId = data.id; 45 | // eslint-disable-next-line no-param-reassign 46 | application.publicIp = data.public_ip; 47 | 48 | ref.authWithCustomToken(data.token, (error) => { 49 | if (error) { 50 | reject(error); 51 | } else { 52 | resolve(); 53 | } 54 | }); 55 | }); 56 | }); 57 | } 58 | 59 | // TODO: move it to a separate initializer 60 | function trackSizeOfReceivedFiles() { 61 | $.subscribe('file_received.p2p', (event, data) => { 62 | Analytics.trackEvent('received', { 63 | event_category: 'file', 64 | event_label: 'size', 65 | value: Math.round(data.info.size / 1000), 66 | }); 67 | }); 68 | } 69 | 70 | application.deferReadiness(); 71 | 72 | checkWebRTCSupport() 73 | .then(clearFileSystem) 74 | .catch((error) => { 75 | // eslint-disable-next-line no-param-reassign 76 | application.error = error; 77 | }) 78 | .then(authenticateToFirebase) 79 | .then(trackSizeOfReceivedFiles) 80 | .then(() => { 81 | application.advanceReadiness(); 82 | }); 83 | } 84 | 85 | export default { 86 | name: 'prerequisites', 87 | initialize, 88 | }; 89 | -------------------------------------------------------------------------------- /app/services/room.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | 3 | // TODO: use Ember.Object.extend() 4 | const Room = function (name, firebaseRef) { 5 | this._ref = firebaseRef; 6 | this.name = name; 7 | }; 8 | 9 | Room.prototype.join = function (user) { 10 | const self = this; 11 | 12 | // Setup Firebase refs 13 | self._connectionRef = self._ref.child('.info/connected'); 14 | self._roomRef = self._ref.child(`rooms/${this.name}`); 15 | self._usersRef = self._roomRef.child('users'); 16 | self._userRef = self._usersRef.child(user.uuid); 17 | 18 | console.log('Room:\t Connecting to: ', this.name); 19 | 20 | self._connectionRef.on('value', (connectionSnapshot) => { 21 | // Once connected (or reconnected) to Firebase 22 | if (connectionSnapshot.val() === true) { 23 | console.log('Firebase: (Re)Connected'); 24 | 25 | // Remove yourself from the room when disconnected 26 | self._userRef.onDisconnect().remove(); 27 | 28 | // Join the room 29 | self._userRef.set(user, (error) => { 30 | if (error) { 31 | console.warn('Firebase: Adding user to the room failed: ', error); 32 | } else { 33 | console.log('Firebase: User added to the room'); 34 | // Create a copy of user data, 35 | // so that deleting properties won't affect the original variable 36 | $.publish('connected.room', $.extend(true, {}, user)); 37 | } 38 | }); 39 | 40 | self._usersRef.on('child_added', (userAddedSnapshot) => { 41 | const addedUser = userAddedSnapshot.val(); 42 | 43 | console.log('Room:\t user_added: ', addedUser); 44 | $.publish('user_added.room', addedUser); 45 | }); 46 | 47 | self._usersRef.on( 48 | 'child_removed', 49 | (userRemovedSnapshot) => { 50 | const removedUser = userRemovedSnapshot.val(); 51 | 52 | console.log('Room:\t user_removed: ', removedUser); 53 | $.publish('user_removed.room', removedUser); 54 | }, 55 | () => { 56 | // Handle case when the whole room is removed from Firebase 57 | $.publish('disconnected.room'); 58 | }, 59 | ); 60 | 61 | self._usersRef.on('child_changed', (userChangedSnapshot) => { 62 | const changedUser = userChangedSnapshot.val(); 63 | 64 | console.log('Room:\t user_changed: ', changedUser); 65 | $.publish('user_changed.room', changedUser); 66 | }); 67 | } else { 68 | console.log('Firebase: Disconnected'); 69 | 70 | $.publish('disconnected.room'); 71 | self._usersRef.off(); 72 | } 73 | }); 74 | 75 | return this; 76 | }; 77 | 78 | Room.prototype.update = function (attrs) { 79 | this._userRef.update(attrs); 80 | }; 81 | 82 | Room.prototype.leave = function () { 83 | this._userRef.remove(); 84 | this._usersRef.off(); 85 | }; 86 | 87 | export default Room; 88 | -------------------------------------------------------------------------------- /app/templates/components/peer-widget.hbs: -------------------------------------------------------------------------------- 1 | {{! Sender related messages }} 2 | {{#if hasSelectedFile}} 3 | {{#popover-confirm 4 | onConfirm=(action "sendFileTransferInquiry") 5 | onCancel=(action "cancelFileTransfer") 6 | confirmButtonLabel="Send" 7 | cancelButtonLabel="Cancel" 8 | filename=filename 9 | }} 10 | Do you want to send "{{filename}}" to "{{label}}"? 11 | {{/popover-confirm}} 12 | {{/if}} 13 | 14 | {{#if isAwaitingResponse}} 15 | {{#popover-confirm 16 | onCancel=(action "abortFileTransfer") 17 | cancelButtonLabel="Cancel" 18 | filename=filename 19 | }} 20 | Waiting for "{{label}}" to accept… 21 | {{/popover-confirm}} 22 | {{/if}} 23 | 24 | {{#if hasDeclinedFileTransfer}} 25 | {{#popover-confirm 26 | onConfirm=(action "cancelFileTransfer") 27 | confirmButtonLabel="Ok" 28 | filename=filename 29 | }} 30 | "{{label}}" has declined your request. 31 | {{/popover-confirm}} 32 | {{/if}} 33 | 34 | {{#if hasError}} 35 | {{#popover-confirm 36 | onConfirm=(action "cancelFileTransfer") 37 | confirmButtonLabel="Ok" 38 | filename=filename 39 | }} 40 | {{partial errorTemplateName}} 41 | {{/popover-confirm}} 42 | {{/if}} 43 | 44 | {{! Recipient related popups }} 45 | {{#if hasReceivedFileInfo}} 46 | {{#popover-confirm 47 | onConfirm=(action "acceptFileTransfer") 48 | onCancel=(action "rejectFileTransfer") 49 | confirmButtonLabel="Save" 50 | cancelButtonLabel="Decline" 51 | filename=filename 52 | }} 53 | "{{label}}" wants to send you "{{filename}}". 54 | {{/popover-confirm}} 55 | {{/if}} 56 | 57 |
58 | {{#if isPreparingFileTransfer}} 59 | 60 | {{else if peer.transfer}} 61 | {{#if peer.transfer.receivingProgress}} 62 | 63 | {{else if peer.transfer.sendingProgress}} 64 | 65 | {{/if}} 66 | {{/if}} 67 | 68 | {{peer-avatar peer=peer onFileDrop=(action "uploadFile")}} 69 |
70 | 71 |
72 |
73 |
74 | {{peer.label}} 75 | 76 | {{#if isPreparingFileTransfer}} 77 |
78 | Bundling files... 79 |

80 | TIP: You can archive files on your device beforehand to speed up the operation 81 |
82 | {{else if isReceivingFile}} 83 |
Receiving file...
84 | {{else if isSendingFile}} 85 |
Sending file...
86 | {{/if}} 87 |
88 |
89 | 90 | {{file-field multiple=true onChange=(action "uploadFile")}} -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | if (process.env.NODE_ENV === 'production') { 4 | // eslint-disable-next-line global-require 5 | require('newrelic'); 6 | } 7 | 8 | // Room server 9 | const http = require('http'); 10 | const path = require('path'); 11 | const express = require('express'); 12 | const logger = require('morgan'); 13 | const bodyParser = require('body-parser'); 14 | const cookieParser = require('cookie-parser'); 15 | const cookieSession = require('cookie-session'); 16 | const compression = require('compression'); 17 | const { v4: uuidv4 } = require('uuid'); 18 | const crypto = require('crypto'); 19 | const FirebaseTokenGenerator = require('firebase-token-generator'); 20 | 21 | const firebaseTokenGenerator = new FirebaseTokenGenerator( 22 | process.env.FIREBASE_SECRET, 23 | ); 24 | const app = express(); 25 | const secret = process.env.SECRET; 26 | const base = ['dist']; 27 | 28 | app.enable('trust proxy'); 29 | 30 | app.use(logger('combined')); 31 | app.use(bodyParser.json()); 32 | app.use(bodyParser.urlencoded({ extended: true })); 33 | app.use(cookieParser()); 34 | app.use( 35 | cookieSession({ 36 | cookie: { 37 | // secure: true, 38 | httpOnly: true, 39 | maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days 40 | }, 41 | secret, 42 | proxy: true, 43 | }), 44 | ); 45 | app.use(compression()); 46 | 47 | // 48 | // Web server 49 | // 50 | base.forEach((dir) => { 51 | const subdirs = ['assets', '.well-known']; 52 | 53 | subdirs.forEach((subdir) => { 54 | app.use( 55 | `/${subdir}`, 56 | express.static(`${dir}/${subdir}`, { 57 | maxAge: 31104000000, // ~1 year 58 | }), 59 | ); 60 | }); 61 | }); 62 | 63 | // 64 | // API server 65 | // 66 | app.get('/', (req, res) => { 67 | const root = path.join(__dirname, base[0]); 68 | console.log({ root }); 69 | res.sendFile(`${root}/index.html`); 70 | }); 71 | 72 | app.get('/rooms/:id', (req, res) => { 73 | const root = path.join(__dirname, base[0]); 74 | res.sendFile(`${root}/index.html`); 75 | }); 76 | 77 | app.get('/room', (req, res) => { 78 | const ip = req.headers['cf-connecting-ip'] || req.ip; 79 | const name = crypto.createHmac('md5', secret).update(ip).digest('hex'); 80 | 81 | res.json({ name }); 82 | }); 83 | 84 | app.get('/auth', (req, res) => { 85 | const ip = req.headers['cf-connecting-ip'] || req.ip; 86 | const uid = uuidv4(); 87 | const token = firebaseTokenGenerator.createToken( 88 | { uid, id: uid }, // will be available in Firebase security rules as 'auth' 89 | { expires: 32503680000 }, // 01.01.3000 00:00 90 | ); 91 | 92 | res.json({ id: uid, token, public_ip: ip }); 93 | }); 94 | 95 | http 96 | .createServer(app) 97 | .listen(process.env.PORT) 98 | .on('listening', () => { 99 | console.log( 100 | `Started ShareDrop web server at http://localhost:${process.env.PORT}...`, 101 | ); 102 | }); 103 | -------------------------------------------------------------------------------- /public/assets/images/avatars/46.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 13 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /app/routes/index.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import $ from 'jquery'; 3 | 4 | import Room from '../services/room'; 5 | 6 | export default Route.extend({ 7 | beforeModel() { 8 | const { error } = window.Sharedrop; 9 | 10 | if (error) { 11 | throw new Error(error); 12 | } 13 | }, 14 | 15 | model() { 16 | // Get room name from the server 17 | return $.getJSON('/room').then((data) => data.name); 18 | }, 19 | 20 | setupController(ctrl, model) { 21 | ctrl.set('model', []); 22 | ctrl.set('hasCustomRoomName', false); 23 | 24 | // Handle room events 25 | $.subscribe('connected.room', ctrl._onRoomConnected.bind(ctrl)); 26 | $.subscribe('disconnected.room', ctrl._onRoomDisconnected.bind(ctrl)); 27 | $.subscribe('user_added.room', ctrl._onRoomUserAdded.bind(ctrl)); 28 | $.subscribe('user_changed.room', ctrl._onRoomUserChanged.bind(ctrl)); 29 | $.subscribe('user_removed.room', ctrl._onRoomUserRemoved.bind(ctrl)); 30 | 31 | // Handle peer events 32 | $.subscribe( 33 | 'incoming_peer_connection.p2p', 34 | ctrl._onPeerP2PIncomingConnection.bind(ctrl), 35 | ); 36 | $.subscribe( 37 | 'incoming_dc_connection.p2p', 38 | ctrl._onPeerDCIncomingConnection.bind(ctrl), 39 | ); 40 | $.subscribe( 41 | 'incoming_dc_connection_error.p2p', 42 | ctrl._onPeerDCIncomingConnectionError.bind(ctrl), 43 | ); 44 | $.subscribe( 45 | 'outgoing_peer_connection.p2p', 46 | ctrl._onPeerP2POutgoingConnection.bind(ctrl), 47 | ); 48 | $.subscribe( 49 | 'outgoing_dc_connection.p2p', 50 | ctrl._onPeerDCOutgoingConnection.bind(ctrl), 51 | ); 52 | $.subscribe( 53 | 'outgoing_dc_connection_error.p2p', 54 | ctrl._onPeerDCOutgoingConnectionError.bind(ctrl), 55 | ); 56 | $.subscribe('disconnected.p2p', ctrl._onPeerP2PDisconnected.bind(ctrl)); 57 | $.subscribe('info.p2p', ctrl._onPeerP2PFileInfo.bind(ctrl)); 58 | $.subscribe('response.p2p', ctrl._onPeerP2PFileResponse.bind(ctrl)); 59 | $.subscribe('file_canceled.p2p', ctrl._onPeerP2PFileCanceled.bind(ctrl)); 60 | $.subscribe('file_received.p2p', ctrl._onPeerP2PFileReceived.bind(ctrl)); 61 | $.subscribe('file_sent.p2p', ctrl._onPeerP2PFileSent.bind(ctrl)); 62 | 63 | // Join the room 64 | const room = new Room(model, window.Sharedrop.ref); 65 | room.join(ctrl.get('you').serialize()); 66 | ctrl.set('room', room); 67 | }, 68 | 69 | renderTemplate() { 70 | this.render(); 71 | 72 | this.render('about_you', { 73 | into: 'application', 74 | outlet: 'about_you', 75 | }); 76 | 77 | const key = 'show-instructions-for-app'; 78 | if (!localStorage.getItem(key)) { 79 | this.send('openModal', 'about_app'); 80 | localStorage.setItem(key, 'yup'); 81 | } 82 | }, 83 | 84 | actions: { 85 | willTransition() { 86 | $.unsubscribe('.room'); 87 | $.unsubscribe('.p2p'); 88 | 89 | // eslint-disable-next-line ember/no-controller-access-in-routes 90 | const controller = this.controllerFor('index'); 91 | controller.get('room').leave(); 92 | 93 | return true; 94 | }, 95 | }, 96 | }); 97 | -------------------------------------------------------------------------------- /app/services/avatar.js: -------------------------------------------------------------------------------- 1 | import Service from '@ember/service'; 2 | import sample from 'lodash/sample'; 3 | import startCase from 'lodash/startCase'; 4 | 5 | const AVATARS = [ 6 | { 7 | name: 'Piglet', 8 | id: '23', 9 | }, 10 | { 11 | name: 'Cat', 12 | id: '36', 13 | }, 14 | { 15 | name: 'Fish', 16 | id: '37', 17 | }, 18 | { 19 | name: 'Fox', 20 | id: '38', 21 | }, 22 | { 23 | name: 'Chicken', 24 | id: '46', 25 | }, 26 | { 27 | name: 'Goat', 28 | id: '50', 29 | }, 30 | { 31 | name: 'Ram', 32 | id: '51', 33 | }, 34 | { 35 | name: 'Sheep', 36 | id: '52', 37 | }, 38 | { 39 | name: 'Bison', 40 | id: '59', 41 | }, 42 | { 43 | name: 'Dog', 44 | id: '61', 45 | }, 46 | { 47 | name: 'Walrus', 48 | id: '62', 49 | }, 50 | { 51 | name: 'Dog', 52 | id: '63', 53 | }, 54 | { 55 | name: 'Monkey', 56 | id: '64', 57 | }, 58 | { 59 | name: 'Bear', 60 | id: '65', 61 | }, 62 | { 63 | name: 'Lion', 64 | id: '66', 65 | }, 66 | { 67 | name: 'Zebra', 68 | id: '67', 69 | }, 70 | { 71 | name: 'Giraffe', 72 | id: '68', 73 | }, 74 | { 75 | name: 'Bear', 76 | id: '71', 77 | }, 78 | { 79 | name: 'Wolf', 80 | id: '74', 81 | }, 82 | { 83 | name: 'Rhino', 84 | id: '86', 85 | }, 86 | { 87 | name: 'Bat', 88 | id: '87', 89 | }, 90 | { 91 | name: 'Cat', 92 | id: '95', 93 | }, 94 | { 95 | name: 'Penguin', 96 | id: '102', 97 | }, 98 | { 99 | name: 'Rhino', 100 | id: '109', 101 | }, 102 | { 103 | name: 'Koala', 104 | id: '112', 105 | }, 106 | ]; 107 | 108 | const PREFIXES = [ 109 | 'adventurous', 110 | 'affable', 111 | 'ambitious', 112 | 'amiable ', 113 | 'amusing', 114 | 'brave', 115 | 'bright', 116 | 'charming', 117 | 'compassionate', 118 | 'convivial', 119 | 'courageous', 120 | 'creative', 121 | 'diligent', 122 | 'easygoing', 123 | 'emotional', 124 | 'energetic', 125 | 'enthusiastic', 126 | 'exuberant', 127 | 'fearless', 128 | 'friendly', 129 | 'funny', 130 | 'generous', 131 | 'gentle', 132 | 'good', 133 | 'helpful', 134 | 'honest', 135 | 'humorous', 136 | 'imaginative', 137 | 'independent', 138 | 'intelligent', 139 | 'intuitive', 140 | 'inventive', 141 | 'kind', 142 | 'loving', 143 | 'loyal', 144 | 'modest', 145 | 'neat', 146 | 'nice', 147 | 'optimistic', 148 | 'passionate', 149 | 'patient', 150 | 'persistent', 151 | 'polite', 152 | 'practical', 153 | 'rational', 154 | 'reliable', 155 | 'reserved', 156 | 'resourceful', 157 | 'romantic', 158 | 'sensible', 159 | 'sensitive', 160 | 'sincere', 161 | 'sympathetic', 162 | 'thoughtful', 163 | 'tough', 164 | 'understanding', 165 | 'versatile', 166 | 'warmhearted', 167 | ]; 168 | 169 | const Avatar = Service.extend({ 170 | get() { 171 | const avatar = sample(AVATARS); 172 | const prefix = sample(PREFIXES); 173 | 174 | return { 175 | url: `/assets/images/avatars/${avatar.id}.svg`, 176 | label: startCase(`${prefix} ${avatar.name}`), 177 | }; 178 | }, 179 | }); 180 | 181 | export default Avatar; 182 | -------------------------------------------------------------------------------- /public/assets/images/avatars/37.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 31 | 33 | 35 | 36 | 37 | 38 | 39 | 41 | 43 | 45 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ShareDrop 4 | 5 | 6 | # ShareDrop is now LimeWire 7 | Dear ShareDrop community, 8 | ShareDrop has been acquired by LimeWire, a leading file sharing platform with integrated AI tools. You can continue to share any files between devices, while benefitting from: 9 | * sharing files between devices in the same network 10 | * anonymous up- & downloads 11 | * end-to-end encryption 12 | * up to 40GB storage for free users 13 | * integrated AI tools for signed-up users 14 | 15 | Visit [sharedrop.io](https://sharedrop.io) or [limewire.com](https://limewire.com) 16 | 17 | The Github repository will stay as-is and you can still go ahead and download and run the classic ShareDrop on your own infrastructure. 18 | 19 | # ShareDrop Classic 20 | 21 | ShareDrop is a web application inspired by Apple [AirDrop](http://support.apple.com/kb/ht4783) service. It allows you to transfer files directly between devices, without having to upload them to any server first. It uses [WebRTC](http://www.webrtc.org) for secure peer-to-peer file transfer and [Firebase](https://www.firebase.com) for presence management and WebRTC signaling. 22 | 23 | ShareDrop allows you to send files to other devices in the same local network (i.e. devices with the same public IP address) without any configuration - simply open on all devices and they will see each other. It also allows you to send files between networks - just click the `+` button in the top right corner of the page to create a room with a unique URL and share this URL with other people you want to send a file to. Once they open this page in a browser on their devices, you'll see each other's avatars. 24 | 25 | The main difference between ShareDrop and AirDrop is that ShareDrop requires Internet connection to discover other devices, while AirDrop doesn't need one, as it creates ad-hoc wireless network between them. On the other hand, ShareDrop allows you to share files between mobile (Android and iOS) and desktop devices and even between networks. 26 | 27 | ## Supported browsers 28 | 29 | - Chrome 30 | - Edge (Chromium based) 31 | - Firefox 32 | - Opera 33 | - Safari 13+ 34 | 35 | ## Local development 36 | 37 | 1. Setup Firebase: 38 | 1. [Sign up](https://www.firebase.com) for a Firebase account and create a database. 39 | 2. Go to "Security Rules" tab, click "Load Rules" button and select `firebase_rules.json` file. 40 | 3. Take note of your database URL and its secret, which can be found in "Secrets" tab. 41 | 2. Run `npm install -g ember-cli` to install Ember CLI. 42 | 3. Run `yarn` to install app dependencies. 43 | 4. Run `cp .env{.sample,}` to create `.env` file. This file will be used by Foreman to set environment variables when running the app locally. 44 | - `SECRET` key is used to encrypt cookies and generate room name based on public IP address for `/` route. It can be any random string - you can generate one using e.g. `date | md5sum` 45 | - `NEW_RELIC_*` keys are only necessary in production 46 | 5. Run `yarn develop` to start the app. 47 | 48 | ## Deployment 49 | 50 | ### Heroku 51 | 52 | Create a new Heroku app: 53 | 54 | ``` 55 | heroku create 56 | ``` 57 | 58 | and push the app to Heroku repo: 59 | 60 | ``` 61 | git push heroku master 62 | ``` 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sharedrop", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "P2P file sharing", 6 | "license": "MIT", 7 | "author": "Szymon Nowak", 8 | "directories": { 9 | "doc": "doc", 10 | "test": "tests" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/szimek/sharedrop.git" 15 | }, 16 | "scripts": { 17 | "build": "ember build --environment=production", 18 | "lint": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*", 19 | "develop": "yarn install --frozen-lockfile && nf --procfile=Procfile.dev start", 20 | "dev": "yarn develop", 21 | "lint:hbs": "ember-template-lint .", 22 | "lint:js": "eslint .", 23 | "start": "ember serve", 24 | "test": "npm-run-all lint:* test:*", 25 | "test:ember": "ember test" 26 | }, 27 | "devDependencies": { 28 | "@ember/jquery": "^1.1.0", 29 | "@ember/optional-features": "^2.0.0", 30 | "@glimmer/component": "^1.0.2", 31 | "@glimmer/tracking": "^1.0.2", 32 | "babel-eslint": "^10.1.0", 33 | "broccoli-asset-rev": "^3.0.0", 34 | "ember-auto-import": "^1.6.0", 35 | "ember-cli": "~3.21.2", 36 | "ember-cli-app-version": "^3.2.0", 37 | "ember-cli-babel": "^7.22.1", 38 | "ember-cli-dependency-checker": "^3.2.0", 39 | "ember-cli-dotenv": "^3.1.0", 40 | "ember-cli-htmlbars": "^5.3.1", 41 | "ember-cli-inject-live-reload": "^2.0.2", 42 | "ember-cli-sass": "^10.0.0", 43 | "ember-cli-sri": "^2.1.1", 44 | "ember-cli-terser": "^4.0.0", 45 | "ember-export-application-global": "^2.0.1", 46 | "ember-fetch": "^8.0.2", 47 | "ember-load-initializers": "^2.1.1", 48 | "ember-maybe-import-regenerator": "^0.1.6", 49 | "ember-qrcode-shim": "^0.4.0", 50 | "ember-qunit": "^4.6.0", 51 | "ember-resolver": "^8.0.2", 52 | "ember-source": "~3.21.3", 53 | "ember-template-lint": "^2.13.0", 54 | "eslint": "^7.10.0", 55 | "eslint-config-airbnb-base": "^14.1.0", 56 | "eslint-config-prettier": "^6.12.0", 57 | "eslint-plugin-ember": "^9.2.0", 58 | "eslint-plugin-import": "^2.22.1", 59 | "eslint-plugin-node": "^11.1.0", 60 | "eslint-plugin-prettier": "^3.0.1", 61 | "foreman": "3.0.1", 62 | "husky": "^4.3.0", 63 | "lint-staged": "^10.4.0", 64 | "loader.js": "^4.7.0", 65 | "npm-run-all": "^4.1.5", 66 | "prettier": "^2.1.2", 67 | "qunit-dom": "^1.5.0", 68 | "sass": "^1.26.11" 69 | }, 70 | "dependencies": { 71 | "@sentry/browser": "5.24.2", 72 | "@sentry/integrations": "5.24.2", 73 | "body-parser": "^1.10.0", 74 | "compression": "^1.2.2", 75 | "cookie-parser": "^1.3.3", 76 | "cookie-session": "^1.1.0", 77 | "express": "^4.10.6", 78 | "firebase-token-generator": "~2.0.0", 79 | "jszip": "^3.5.0", 80 | "lodash": "^4.17.20", 81 | "morgan": "^1.5.0", 82 | "newrelic": "^6.13.1", 83 | "stream": "^0.0.2", 84 | "uuid": "^8.3.1" 85 | }, 86 | "husky": { 87 | "hooks": { 88 | "pre-commit": "lint-staged" 89 | } 90 | }, 91 | "lint-staged": { 92 | "*.js": [ 93 | "yarn run prettier --write", 94 | "yarn run lint:hbs", 95 | "yarn run lint:js" 96 | ] 97 | }, 98 | "engines": { 99 | "node": "^14.0.0" 100 | }, 101 | "ember": { 102 | "edition": "octane" 103 | }, 104 | "ember-addon": { 105 | "paths": [ 106 | "lib/google-analytics" 107 | ] 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /public/assets/images/avatars/36.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 32 | 33 | 35 | 37 | 39 | 41 | 43 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /public/assets/images/avatars/38.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 11 | 13 | 14 | 15 | 17 | 19 | 20 | 23 | 26 | 27 | 29 | 33 | 34 | 35 | 36 | 37 | 39 | 41 | 43 | 45 | 46 | 47 | 48 | 49 | 51 | 53 | 55 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /app/components/peer-avatar.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { alias } from '@ember/object/computed'; 3 | import { later } from '@ember/runloop'; 4 | import $ from 'jquery'; 5 | 6 | export default Component.extend({ 7 | tagName: 'img', 8 | classNames: ['gravatar'], 9 | attributeBindings: [ 10 | 'src', 11 | 'alt', 12 | 'title', 13 | 'data-sending-progress', 14 | 'data-receiving-progress', 15 | ], 16 | src: alias('peer.avatarUrl'), 17 | alt: alias('peer.label'), 18 | title: alias('peer.uuid'), 19 | 'data-sending-progress': alias('peer.transfer.sendingProgress'), 20 | 'data-receiving-progress': alias('peer.transfer.receivingProgress'), 21 | 22 | toggleTransferCompletedClass() { 23 | const className = 'transfer-completed'; 24 | 25 | later( 26 | this, 27 | function toggleClass() { 28 | $(this.element) 29 | .parent('.avatar') 30 | .addClass(className) 31 | .delay(2000) 32 | .queue(function removeClass() { 33 | $(this).removeClass(className).dequeue(); 34 | }); 35 | }, 36 | 250, 37 | ); 38 | }, 39 | 40 | init(...args) { 41 | this._super(args); 42 | 43 | this.toggleTransferCompletedClass = this.toggleTransferCompletedClass.bind( 44 | this, 45 | ); 46 | }, 47 | 48 | didInsertElement(...args) { 49 | this._super(args); 50 | const { peer } = this; 51 | 52 | peer.on('didReceiveFile', this.toggleTransferCompletedClass); 53 | peer.on('didSendFile', this.toggleTransferCompletedClass); 54 | }, 55 | 56 | willDestroyElement(...args) { 57 | this._super(args); 58 | const { peer } = this; 59 | 60 | peer.off('didReceiveFile', this.toggleTransferCompletedClass); 61 | peer.off('didSendFile', this.toggleTransferCompletedClass); 62 | }, 63 | 64 | // Delegate click to hidden file field in peer template 65 | click() { 66 | if (this.canSendFile()) { 67 | $(this.element).closest('.peer').find('input[type=file]').click(); 68 | } 69 | }, 70 | 71 | // Handle drop events 72 | dragEnter(event) { 73 | this.cancelEvent(event); 74 | 75 | $(this.element).parent('.avatar').addClass('hover'); 76 | }, 77 | 78 | dragOver(event) { 79 | this.cancelEvent(event); 80 | }, 81 | 82 | dragLeave() { 83 | $(this.element).parent('.avatar').removeClass('hover'); 84 | }, 85 | 86 | drop(event) { 87 | this.cancelEvent(event); 88 | $(this.element).parent('.avatar').removeClass('hover'); 89 | 90 | const { peer } = this; 91 | const dt = event.originalEvent.dataTransfer; 92 | const { files } = dt; 93 | 94 | if (this.canSendFile()) { 95 | if (!this.isTransferableBundle(files)) { 96 | peer.setProperties({ 97 | state: 'error', 98 | errorCode: 'multiple-files', 99 | }); 100 | } else { 101 | this.onFileDrop({ files }); 102 | } 103 | } 104 | }, 105 | 106 | cancelEvent(event) { 107 | event.stopPropagation(); 108 | event.preventDefault(); 109 | }, 110 | 111 | canSendFile() { 112 | const { peer } = this; 113 | 114 | // Can't send files if another file transfer is already in progress 115 | return !( 116 | peer.get('state') === 'is_preparing_file_transfer' || 117 | peer.get('transfer.file') || 118 | peer.get('transfer.info') 119 | ); 120 | }, 121 | 122 | isTransferableBundle(files) { 123 | if (files.length === 1 && files[0] instanceof window.File) return true; 124 | 125 | const fileSizeLimit = 50 * 1024 * 1024; 126 | const bundleSizeLimit = 200 * 1024 * 1024; 127 | let aggregatedSize = 0; 128 | // eslint-disable-next-line no-restricted-syntax 129 | for (const file of files) { 130 | if (!(file instanceof window.File)) { 131 | return false; 132 | } 133 | if (file.size > fileSizeLimit) { 134 | return false; 135 | } 136 | aggregatedSize += file.size; 137 | if (aggregatedSize > bundleSizeLimit) { 138 | return false; 139 | } 140 | } 141 | return true; 142 | }, 143 | }); 144 | -------------------------------------------------------------------------------- /public/assets/images/avatars/86.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 11 | 13 | 14 | 16 | 17 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 31 | 33 | 35 | 36 | 41 | 42 | 43 | 44 | 46 | 48 | 50 | 52 | 53 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /public/assets/images/avatars/64.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 25 | 27 | 29 | 31 | 32 | 38 | 39 | 40 | 41 | 43 | 45 | 47 | 49 | 50 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /public/assets/images/avatars/87.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 12 | 15 | 16 | 18 | 20 | 21 | 22 | 24 | 25 | 26 | 28 | 30 | 32 | 34 | 35 | 40 | 41 | 43 | 45 | 47 | 49 | 50 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /public/assets/images/avatars/112.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 32 | 34 | 36 | 38 | 39 | 45 | 46 | 47 | 48 | 50 | 52 | 54 | 56 | 57 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /public/assets/images/avatars/109.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 23 | 24 | 25 | 27 | 29 | 31 | 33 | 34 | 40 | 41 | 42 | 43 | 45 | 47 | 49 | 51 | 52 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /public/assets/images/avatars/50.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 12 | 14 | 15 | 18 | 20 | 21 | 22 | 23 | 25 | 27 | 29 | 31 | 32 | 38 | 39 | 40 | 41 | 43 | 45 | 47 | 49 | 50 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /public/assets/images/avatars/63.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 14 | 16 | 18 | 20 | 21 | 22 | 23 | 25 | 27 | 29 | 31 | 32 | 38 | 39 | 40 | 41 | 43 | 45 | 47 | 49 | 50 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /public/assets/images/avatars/23.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 11 | 13 | 14 | 15 | 17 | 19 | 20 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 32 | 34 | 36 | 38 | 39 | 45 | 46 | 47 | 48 | 50 | 52 | 54 | 56 | 57 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /public/assets/images/avatars/61.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 13 | 15 | 19 | 22 | 23 | 24 | 25 | 27 | 29 | 31 | 33 | 34 | 40 | 41 | 42 | 43 | 45 | 47 | 49 | 51 | 52 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /app/components/peer-widget.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { computed } from '@ember/object'; 3 | import { alias, equal, gt } from '@ember/object/computed'; 4 | import JSZip from 'jszip'; 5 | 6 | export default Component.extend({ 7 | classNames: ['peer'], 8 | classNameBindings: ['peer.peer.state'], 9 | 10 | peer: null, 11 | hasCustomRoomName: false, 12 | webrtc: null, // TODO inject webrtc as a service 13 | 14 | label: alias('peer.label'), 15 | 16 | isIdle: equal('peer.state', 'idle'), 17 | isPreparingFileTransfer: equal('peer.state', 'is_preparing_file_transfer'), 18 | hasSelectedFile: equal('peer.state', 'has_selected_file'), 19 | isSendingFileInfo: equal('peer.state', 'sending_file_info'), 20 | isAwaitingFileInfo: equal('peer.state', 'awaiting_file_info'), 21 | isAwaitingResponse: equal('peer.state', 'awaiting_response'), 22 | hasReceivedFileInfo: equal('peer.state', 'received_file_info'), 23 | hasDeclinedFileTransfer: equal('peer.state', 'declined_file_transfer'), 24 | hasError: equal('peer.state', 'error'), 25 | 26 | isReceivingFile: gt('peer.transfer.receivingProgress', 0), 27 | isSendingFile: gt('peer.transfer.sendingProgress', 0), 28 | 29 | filename: computed('peer.transfer.{file,info}', function () { 30 | const file = this.get('peer.transfer.file'); 31 | const info = this.get('peer.transfer.info'); 32 | 33 | if (file) { 34 | return file.name; 35 | } 36 | if (info) { 37 | return info.name; 38 | } 39 | 40 | return null; 41 | }), 42 | 43 | errorTemplateName: computed('peer.errorCode', function () { 44 | const errorCode = this.get('peer.errorCode'); 45 | 46 | return errorCode ? `errors/popovers/${errorCode}` : null; 47 | }), 48 | 49 | actions: { 50 | // TODO: rename to something more meaningful (e.g. askIfWantToSendFile) 51 | uploadFile(data) { 52 | const { peer } = this; 53 | const { files } = data; 54 | 55 | peer.set('state', 'is_preparing_file_transfer'); 56 | peer.set('bundlingProgress', 0); 57 | 58 | // Cache the file, so that it's available 59 | // when the response from the recipient comes in 60 | this._reduceFiles(files).then((file) => { 61 | peer.set('transfer.file', file); 62 | peer.set('state', 'has_selected_file'); 63 | }); 64 | }, 65 | 66 | sendFileTransferInquiry() { 67 | const { webrtc } = this; 68 | const { peer } = this; 69 | 70 | webrtc.connect(peer.get('peer.id')); 71 | peer.set('state', 'establishing_connection'); 72 | }, 73 | 74 | cancelFileTransfer() { 75 | this._cancelFileTransfer(); 76 | }, 77 | 78 | abortFileTransfer() { 79 | this._cancelFileTransfer(); 80 | 81 | const { webrtc } = this; 82 | const connection = this.get('peer.peer.connection'); 83 | 84 | webrtc.sendCancelRequest(connection); 85 | }, 86 | 87 | acceptFileTransfer() { 88 | const { peer } = this; 89 | 90 | this._sendFileTransferResponse(true); 91 | 92 | peer.get('peer.connection').on('receiving_progress', (progress) => { 93 | peer.set('transfer.receivingProgress', progress); 94 | }); 95 | peer.set('state', 'sending_file_data'); 96 | }, 97 | 98 | rejectFileTransfer() { 99 | const { peer } = this; 100 | 101 | this._sendFileTransferResponse(false); 102 | peer.set('transfer.info', null); 103 | peer.set('state', 'idle'); 104 | }, 105 | }, 106 | 107 | _cancelFileTransfer() { 108 | const { peer } = this; 109 | 110 | peer.setProperties({ 111 | 'transfer.file': null, 112 | state: 'idle', 113 | }); 114 | }, 115 | 116 | _sendFileTransferResponse(response) { 117 | const { webrtc } = this; 118 | const { peer } = this; 119 | const connection = peer.get('peer.connection'); 120 | 121 | webrtc.sendFileResponse(connection, response); 122 | }, 123 | 124 | async _reduceFiles(files) { 125 | const { peer } = this; 126 | 127 | if (files.length === 1) { 128 | return Promise.resolve(files[0]); 129 | } 130 | 131 | const zip = new JSZip(); 132 | 133 | Array.prototype.forEach.call(files, (file) => { 134 | zip.file(file.name, file); 135 | }); 136 | 137 | const blob = await zip.generateAsync( 138 | { type: 'blob', streamFiles: true }, 139 | (metadata) => { 140 | peer.set('bundlingProgress', metadata.percent / 100); 141 | }, 142 | ); 143 | 144 | return new File( 145 | [blob], 146 | `sharedrop-${new Date() 147 | .toISOString() 148 | .substring(0, 19) 149 | .replace('T', '-')}.zip`, 150 | { 151 | type: 'application/zip', 152 | }, 153 | ); 154 | }, 155 | }); 156 | -------------------------------------------------------------------------------- /app/styles/modules/_users.sass: -------------------------------------------------------------------------------- 1 | $user-size: 76px 2 | 3 | .user 4 | user-select: none 5 | 6 | .peer 7 | position: absolute 8 | left: 50% 9 | bottom: 300px 10 | width: $user-size 11 | height: $user-size 12 | margin-left: -$user-size / 2 13 | text-align: center 14 | .avatar 15 | position: relative 16 | width: $user-size 17 | height: $user-size 18 | transition: all .2s ease-in-out 19 | svg 20 | top: 0 21 | bottom: 0 22 | z-index: -1 23 | 24 | .gravatar 25 | position: absolute 26 | top: 5px 27 | left: 5px 28 | z-index: 1 29 | border: 1px solid #c0c0c0 30 | box-shadow: rgba(black, 0.2) 0 0 3px 31 | width: $user-size - 10 32 | height: $user-size - 10 33 | border-radius: 50% 34 | animation: shadow .8s ease-in 35 | transition: all .2s ease-in-out 36 | 37 | .user-info 38 | position: absolute 39 | top: $user-size 40 | left: 50% 41 | width: 140px 42 | margin-left: -70px 43 | .user-label, .user-email 44 | font-weight: bold 45 | color: #606060 46 | padding-bottom: .4rem 47 | .user-email 48 | font-size: 1rem 49 | +ellipsis 50 | .user-label 51 | font-size: 1.4rem 52 | .user-ip 53 | position: relative 54 | display: inline-block 55 | font-size: 1rem 56 | line-height: 1.2em 57 | color: #808080 58 | > strong 59 | display: block 60 | .user-connection-status 61 | position: absolute 62 | left: -1rem 63 | top: 50% 64 | margin-top: -.3rem 65 | width: .6rem 66 | height: .6rem 67 | border-radius: 50% 68 | &.disconnected 69 | display: none 70 | &.connecting 71 | background: rgba($blue,.5) 72 | animation: blink .75s infinite 73 | &.connected 74 | background: rgba($green,.8) 75 | 76 | select 77 | appearance: none 78 | border: none 79 | font-size: 1rem 80 | color: #808080 81 | padding-right: 10px 82 | outline: none 83 | background: transparent url("../images/select-arrow.svg") no-repeat 66px 50% 84 | // firefox fix - remove arrow from select 85 | text-indent: 0.01px 86 | text-overflow: '' 87 | 88 | &:nth-of-type(2) 89 | margin-left: -186px 90 | bottom: 225px 91 | &:nth-of-type(3) 92 | margin-left: 120px 93 | bottom: 225px 94 | &:nth-of-type(4) 95 | margin-left: -186px 96 | bottom: 365px 97 | &:nth-of-type(5) 98 | margin-left: 120px 99 | bottom: 365px 100 | &:nth-of-type(6) 101 | margin-left: -326px 102 | bottom: 180px 103 | &:nth-of-type(7) 104 | margin-left: 260px 105 | bottom: 180px 106 | &:nth-of-type(8) 107 | margin-left: -366px 108 | bottom: 320px 109 | &:nth-of-type(9) 110 | margin-left: 300px 111 | bottom: 320px 112 | &:nth-of-type(10) 113 | margin-left: -436px 114 | bottom: 90px 115 | &:nth-of-type(11) 116 | margin-left: 370px 117 | bottom: 90px 118 | &:nth-of-type(12) 119 | bottom: 400px 120 | &:nth-of-type(13) 121 | margin-left: -236px 122 | bottom: 90px 123 | &:nth-of-type(14) 124 | margin-left: 170px 125 | bottom: 90px 126 | 127 | &.you 128 | .peer 129 | bottom: 90px 130 | 131 | &.others 132 | .peer 133 | .avatar 134 | cursor: pointer 135 | &:hover, &.hover 136 | transform: scale(1.1, 1.1) 137 | .gravatar 138 | border-color: rgba($blue,.8) 139 | &::after 140 | opacity: 0 141 | position: absolute 142 | pointer-events: none 143 | top: 5px 144 | left: 5px 145 | z-index: 100 146 | content: "L" 147 | color: white 148 | font-size: 3rem 149 | font-weight: bold 150 | background: rgba($green,.8) 151 | border: 1px solid white 152 | transform: scaleX(-1) rotate(-45deg) 153 | 154 | // +circle 155 | display: inline-block 156 | width: 66px 157 | height: $user-size - 10 158 | line-height: $user-size - 10 159 | text-align: center 160 | vertical-align: middle 161 | border-radius: 50% 162 | 163 | transition: opacity .3s 164 | &.transfer-completed 165 | &::after 166 | opacity: 1 167 | 168 | @keyframes blink 169 | 0% 170 | opacity: 1 171 | 50% 172 | opacity: 0 173 | 174 | @keyframes shadow 175 | 0% 176 | opacity: 0 177 | 50% 178 | opacity: 1 179 | box-shadow: rgba(black, .3) 0 0 15px 180 | -------------------------------------------------------------------------------- /public/assets/images/avatars/52.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 11 | 13 | 14 | 16 | 19 | 21 | 27 | 28 | 29 | 30 | 32 | 34 | 36 | 38 | 39 | 45 | 46 | 47 | 48 | 50 | 52 | 54 | 56 | 57 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /app/services/file.js: -------------------------------------------------------------------------------- 1 | import { Promise } from 'rsvp'; 2 | 3 | const File = function (options) { 4 | const self = this; 5 | 6 | this.name = options.name; 7 | this.localName = `${new Date().getTime()}-${this.name}`; 8 | this.size = options.size; 9 | this.type = options.type; 10 | 11 | this._reset(); 12 | 13 | return new Promise((resolve, reject) => { 14 | const requestFileSystem = 15 | window.requestFileSystem || window.webkitRequestFileSystem; 16 | 17 | requestFileSystem( 18 | window.TEMPORARY, 19 | options.size, 20 | (filesystem) => { 21 | self.filesystem = filesystem; 22 | resolve(self); 23 | }, 24 | (error) => { 25 | self.errorHandler(error); 26 | reject(error); 27 | }, 28 | ); 29 | }); 30 | }; 31 | 32 | File.removeAll = function () { 33 | return new Promise((resolve, reject) => { 34 | const filer = new window.Filer(); 35 | 36 | filer.init( 37 | { persistent: false }, 38 | () => { 39 | filer.ls('/', (entries) => { 40 | function rm(entry) { 41 | if (entry) { 42 | filer.rm(entry, () => { 43 | rm(entries.pop()); 44 | }); 45 | } else { 46 | resolve(); 47 | } 48 | } 49 | 50 | rm(entries.pop()); 51 | }); 52 | }, 53 | (error) => { 54 | console.log(error); 55 | reject(error); 56 | }, 57 | ); 58 | }); 59 | }; 60 | 61 | File.prototype.append = function (data) { 62 | const self = this; 63 | const options = { 64 | create: this.create, 65 | }; 66 | 67 | return new Promise((resolve, reject) => { 68 | self.filesystem.root.getFile( 69 | self.localName, 70 | options, 71 | (fileEntry) => { 72 | if (self.create) { 73 | self.create = false; 74 | } 75 | 76 | self.fileEntry = fileEntry; 77 | 78 | fileEntry.createWriter( 79 | (writer) => { 80 | const blob = new Blob(data, { type: self.type }); 81 | 82 | // console.log('File: Appending ' + blob.size + ' bytes at ' + self.seek); 83 | 84 | // eslint-disable-next-line no-param-reassign 85 | writer.onwriteend = function () { 86 | self.seek += blob.size; 87 | resolve(fileEntry); 88 | }; 89 | 90 | // eslint-disable-next-line no-param-reassign 91 | writer.onerror = function (error) { 92 | self.errorHandler(error); 93 | reject(error); 94 | }; 95 | 96 | writer.seek(self.seek); 97 | writer.write(blob); 98 | }, 99 | (error) => { 100 | self.errorHandler(error); 101 | reject(error); 102 | }, 103 | ); 104 | }, 105 | (error) => { 106 | self.errorHandler(error); 107 | reject(error); 108 | }, 109 | ); 110 | }); 111 | }; 112 | 113 | File.prototype.save = function () { 114 | const self = this; 115 | 116 | console.log('File: Saving file: ', this.fileEntry); 117 | 118 | const a = document.createElement('a'); 119 | a.download = this.name; 120 | 121 | function finish(link) { 122 | document.body.appendChild(a); 123 | link.addEventListener('click', () => { 124 | // Remove file entry from filesystem. 125 | setTimeout(() => { 126 | self.remove().then(self._reset.bind(self)); 127 | }, 100); // Hack, but otherwise browser doesn't save the file at all. 128 | 129 | link.parentNode.removeChild(a); 130 | }); 131 | link.click(); 132 | } 133 | 134 | if (this._isWebKit()) { 135 | a.href = this.fileEntry.toURL(); 136 | finish(a); 137 | } else { 138 | this.fileEntry.file((file) => { 139 | const URL = window.URL || window.webkitURL; 140 | a.href = URL.createObjectURL(file); 141 | finish(a); 142 | }); 143 | } 144 | }; 145 | 146 | File.prototype.errorHandler = function (error) { 147 | console.error('File error: ', error); 148 | }; 149 | 150 | File.prototype.remove = function () { 151 | const self = this; 152 | 153 | return new Promise((resolve, reject) => { 154 | self.filesystem.root.getFile( 155 | self.localName, 156 | { create: false }, 157 | (fileEntry) => { 158 | fileEntry.remove( 159 | () => { 160 | console.debug(`File: Removed file: ${self.localName}`); 161 | resolve(fileEntry); 162 | }, 163 | (error) => { 164 | self.errorHandler(error); 165 | reject(error); 166 | }, 167 | ); 168 | }, 169 | (error) => { 170 | self.errorHandler(error); 171 | reject(error); 172 | }, 173 | ); 174 | }); 175 | }; 176 | 177 | File.prototype._reset = function () { 178 | this.create = true; 179 | this.filesystem = null; 180 | this.fileEntry = null; 181 | this.seek = 0; 182 | }; 183 | 184 | File.prototype._isWebKit = function () { 185 | return !!window.webkitRequestFileSystem; 186 | }; 187 | 188 | export default File; 189 | -------------------------------------------------------------------------------- /public/assets/images/avatars/40.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 24 | 27 | 28 | 30 | 32 | 33 | 34 | 39 | 44 | 48 | 49 | 50 | 51 | 52 | 54 | 56 | 58 | 60 | 61 | 62 | 63 | 64 | 66 | 68 | 70 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /public/assets/images/avatars/74.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 11 | 13 | 18 | 19 | 21 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /public/assets/images/avatars/65.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 18 | 33 | 34 | 35 | 36 | 38 | 40 | 42 | 44 | 45 | 51 | 52 | 53 | 54 | 56 | 58 | 60 | 62 | 63 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /public/assets/images/avatars/66.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 25 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /public/assets/images/avatars/59.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 12 | 14 | 15 | 16 | 25 | 33 | 34 | 36 | 38 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 54 | 56 | 58 | 60 | 61 | 66 | 67 | 68 | 69 | 71 | 73 | 75 | 77 | 78 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /public/assets/images/sharedrop-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/index.js: -------------------------------------------------------------------------------- 1 | import Controller, { inject as controller } from '@ember/controller'; 2 | import { alias } from '@ember/object/computed'; 3 | import $ from 'jquery'; 4 | 5 | import WebRTC from '../services/web-rtc'; 6 | import Peer from '../models/peer'; 7 | 8 | export default Controller.extend({ 9 | application: controller('application'), 10 | you: alias('application.you'), 11 | room: null, 12 | webrtc: null, 13 | 14 | _onRoomConnected(event, data) { 15 | const { you } = this; 16 | const { room } = this; 17 | 18 | you.get('peer').setProperties(data.peer); 19 | // eslint-disable-next-line no-param-reassign 20 | delete data.peer; 21 | you.setProperties(data); 22 | 23 | // Initialize WebRTC 24 | this.set( 25 | 'webrtc', 26 | new WebRTC(you.get('uuid'), { 27 | room: room.name, 28 | firebaseRef: window.Sharedrop.ref, 29 | }), 30 | ); 31 | }, 32 | 33 | _onRoomDisconnected() { 34 | this.model.clear(); 35 | this.set('webrtc', null); 36 | }, 37 | 38 | _onRoomUserAdded(event, data) { 39 | const { you } = this; 40 | 41 | if (you.get('uuid') !== data.uuid) { 42 | this._addPeer(data); 43 | } 44 | }, 45 | 46 | _addPeer(attrs) { 47 | const peerAttrs = attrs.peer; 48 | 49 | // eslint-disable-next-line no-param-reassign 50 | delete attrs.peer; 51 | const peer = Peer.create(attrs); 52 | peer.get('peer').setProperties(peerAttrs); 53 | 54 | this.model.pushObject(peer); 55 | }, 56 | 57 | _onRoomUserChanged(event, data) { 58 | const peers = this.model; 59 | const peer = peers.findBy('uuid', data.uuid); 60 | const peerAttrs = data.peer; 61 | const defaults = { 62 | uuid: null, 63 | public_ip: null, 64 | }; 65 | 66 | if (peer) { 67 | // eslint-disable-next-line no-param-reassign 68 | delete data.peer; 69 | // Firebase doesn't return keys with null values, 70 | // so we have to add them back. 71 | peer.setProperties($.extend({}, defaults, data)); 72 | peer.get('peer').setProperties(peerAttrs); 73 | } 74 | }, 75 | 76 | _onRoomUserRemoved(event, data) { 77 | const peers = this.model; 78 | const peer = peers.findBy('uuid', data.uuid); 79 | 80 | peers.removeObject(peer); 81 | }, 82 | 83 | _onPeerP2PIncomingConnection(event, data) { 84 | const { connection } = data; 85 | const peers = this.model; 86 | const peer = peers.findBy('peer.id', connection.peer); 87 | 88 | // Don't switch to 'connecting' state on incoming connection, 89 | // as p2p connection may still fail. 90 | peer.set('peer.connection', connection); 91 | }, 92 | 93 | _onPeerDCIncomingConnection(event, data) { 94 | const { connection } = data; 95 | const peers = this.model; 96 | const peer = peers.findBy('peer.id', connection.peer); 97 | 98 | peer.set('peer.state', 'connected'); 99 | }, 100 | 101 | _onPeerDCIncomingConnectionError(event, data) { 102 | const { connection, error } = data; 103 | const peers = this.model; 104 | const peer = peers.findBy('peer.id', connection.peer); 105 | 106 | switch (error.type) { 107 | case 'failed': 108 | peer.setProperties({ 109 | 'peer.connection': null, 110 | 'peer.state': 'disconnected', 111 | state: 'error', 112 | errorCode: 'connection-failed', 113 | }); 114 | break; 115 | case 'disconnected': 116 | // TODO: notify both sides 117 | break; 118 | default: 119 | break; 120 | } 121 | }, 122 | 123 | _onPeerP2POutgoingConnection(event, data) { 124 | const { connection } = data; 125 | const peers = this.model; 126 | const peer = peers.findBy('peer.id', connection.peer); 127 | 128 | peer.setProperties({ 129 | 'peer.connection': connection, 130 | 'peer.state': 'connecting', 131 | }); 132 | }, 133 | 134 | _onPeerDCOutgoingConnection(event, data) { 135 | const { connection } = data; 136 | const peers = this.model; 137 | const peer = peers.findBy('peer.id', connection.peer); 138 | const file = peer.get('transfer.file'); 139 | const { webrtc } = this; 140 | const info = webrtc.getFileInfo(file); 141 | 142 | peer.set('peer.state', 'connected'); 143 | peer.set('state', 'awaiting_response'); 144 | 145 | webrtc.sendFileInfo(connection, info); 146 | console.log('Sending a file info...', info); 147 | }, 148 | 149 | _onPeerDCOutgoingConnectionError(event, data) { 150 | const { connection, error } = data; 151 | const peers = this.model; 152 | const peer = peers.findBy('peer.id', connection.peer); 153 | 154 | switch (error.type) { 155 | case 'failed': 156 | peer.setProperties({ 157 | 'peer.connection': null, 158 | 'peer.state': 'disconnected', 159 | state: 'error', 160 | errorCode: 'connection-failed', 161 | }); 162 | break; 163 | default: 164 | break; 165 | } 166 | }, 167 | 168 | _onPeerP2PDisconnected(event, data) { 169 | const { connection } = data; 170 | const peers = this.model; 171 | const peer = peers.findBy('peer.id', connection.peer); 172 | 173 | if (peer) { 174 | peer.set('peer.connection', null); 175 | peer.set('peer.state', 'disconnected'); 176 | } 177 | }, 178 | 179 | _onPeerP2PFileInfo(event, data) { 180 | console.log('Peer:\t Received file info', data); 181 | 182 | const { connection, info } = data; 183 | const peers = this.model; 184 | const peer = peers.findBy('peer.id', connection.peer); 185 | 186 | peer.set('transfer.info', info); 187 | peer.set('state', 'received_file_info'); 188 | }, 189 | 190 | _onPeerP2PFileResponse(event, data) { 191 | console.log('Peer:\t Received file response', data); 192 | 193 | const { connection, response } = data; 194 | const peers = this.model; 195 | const peer = peers.findBy('peer.id', connection.peer); 196 | const { webrtc } = this; 197 | 198 | if (response) { 199 | const file = peer.get('transfer.file'); 200 | 201 | connection.on('sending_progress', (progress) => { 202 | peer.set('transfer.sendingProgress', progress); 203 | }); 204 | webrtc.sendFile(connection, file); 205 | peer.set('state', 'receiving_file_data'); 206 | } else { 207 | peer.set('state', 'declined_file_transfer'); 208 | } 209 | }, 210 | 211 | _onPeerP2PFileCanceled(event, data) { 212 | const { connection } = data; 213 | const peers = this.model; 214 | const peer = peers.findBy('peer.id', connection.peer); 215 | 216 | connection.close(); 217 | peer.set('transfer.receivingProgress', 0); 218 | peer.set('transfer.info', null); 219 | peer.set('state', 'idle'); 220 | }, 221 | 222 | _onPeerP2PFileReceived(event, data) { 223 | console.log('Peer:\t Received file', data); 224 | 225 | const { connection } = data; 226 | const peers = this.model; 227 | const peer = peers.findBy('peer.id', connection.peer); 228 | 229 | connection.close(); 230 | peer.set('transfer.receivingProgress', 0); 231 | peer.set('transfer.info', null); 232 | peer.set('state', 'idle'); 233 | peer.trigger('didReceiveFile'); 234 | }, 235 | 236 | _onPeerP2PFileSent(event, data) { 237 | console.log('Peer:\t Sent file', data); 238 | 239 | const { connection } = data; 240 | const peers = this.model; 241 | const peer = peers.findBy('peer.id', connection.peer); 242 | 243 | peer.set('transfer.sendingProgress', 0); 244 | peer.set('transfer.file', null); 245 | peer.set('state', 'idle'); 246 | peer.trigger('didSendFile'); 247 | }, 248 | }); 249 | -------------------------------------------------------------------------------- /public/assets/images/avatars/95.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 13 | 15 | 16 | 22 | 28 | 34 | 35 | 36 | 42 | 48 | 54 | 55 | 56 | 57 | 58 | 60 | 62 | 64 | 66 | 67 | 73 | 74 | 75 | 76 | 78 | 80 | 82 | 84 | 85 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /public/assets/images/avatars/71.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 13 | 17 | 18 | 19 | 22 | 26 | 27 | 33 | 35 | 36 | 37 | 38 | 39 | 42 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 64 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 89 | 96 | 97 | 99 | 100 | 101 | 102 | --------------------------------------------------------------------------------