diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..80c2480 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Git +.git +.gitignore + +# Documentation +README.md +LICENSE + +# Development files +.ruby-version +.rspec +Gemfile.lock + +# Test files +spec/ + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Docker files +Dockerfile +docker-compose*.yml +.dockerignore + +# Conductor +conductor.json diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..e1c7ca2 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,59 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - master + tags: + - 'v*' + pull_request: + branches: + - master + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.ruby-version b/.ruby-version index 37c2961..9c25013 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.2 +3.3.6 diff --git a/Dockerfile b/Dockerfile index 3086aaa..7065aa8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,16 @@ -FROM ruby:2.7.0 -RUN mkdir /app +FROM ruby:3.3.6-slim + +RUN apt-get update && apt-get install -y \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app -ADD Gemfile . -ADD Gemfile.lock . -RUN gem install bundler:1.17.2 -RUN bundle install --without test -ADD . . + +COPY Gemfile Gemfile.lock ./ +RUN bundle config set --local without 'test development' \ + && bundle install + +COPY . . + EXPOSE 80 -CMD ["rackup", "-o", "0.0.0.0", "-p", "80"] +CMD ["bundle", "exec", "rackup", "-o", "0.0.0.0", "-p", "80"] diff --git a/Gemfile b/Gemfile index cf4663a..a082bd6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,22 +1,27 @@ -# A sample Gemfile +# DL.Center - File sharing application source "https://rubygems.org" -gem 'sinatra' -gem 'sinatra-contrib' -gem 'sinatra-websocket' -gem 'uuid' -gem 'ruby-prof' -gem 'rubyzip' -gem 'zip_tricks' +ruby '>= 3.3.0' +# Sinatra 3.x is the last version compatible with Rack 2.x (required by thin/sinatra-websocket) +gem 'sinatra', '~> 3.2' +gem 'sinatra-contrib', '~> 3.2' +gem 'sinatra-websocket', '~> 0.3.1' +gem 'uuid', '~> 2.3' +gem 'rubyzip', '~> 2.4' +gem 'zip_tricks', '~> 5.6' +gem 'thin', '~> 1.8' +gem 'rackup', '~> 1.0' -group :test do - gem 'rspec' - gem 'rack-test' - gem 'simplecov', :require => false - gem 'rubocop' - gem 'ffaker' - gem 'mocha' +group :development do + gem 'ruby-prof', '~> 1.7' end -# gem "rails" +group :test do + gem 'rspec', '~> 3.13' + gem 'rack-test', '~> 2.2' + gem 'simplecov', '~> 0.22', require: false + gem 'rubocop', '~> 1.69' + gem 'ffaker', '~> 2.23' + gem 'mocha', '~> 2.7' +end diff --git a/Gemfile.lock b/Gemfile.lock index a2a9b9b..1546caa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,112 +1,136 @@ GEM remote: https://rubygems.org/ specs: - addressable (2.7.0) - public_suffix (>= 2.0.2, < 5.0) - ast (2.4.2) - daemons (1.4.0) - diff-lcs (1.4.4) - docile (1.4.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + daemons (1.4.1) + diff-lcs (1.6.2) + docile (1.4.1) em-websocket (0.3.8) addressable (>= 2.1.1) eventmachine (>= 0.12.9) eventmachine (1.2.7) - ffaker (2.18.0) + ffaker (2.25.0) + json (2.17.1) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) macaddr (1.7.2) systemu (~> 2.6.5) - mocha (1.12.0) - multi_json (1.15.0) - mustermann (1.1.1) + mocha (2.8.2) + ruby2_keywords (>= 0.0.5) + multi_json (1.18.0) + mustermann (3.0.4) ruby2_keywords (~> 0.0.1) - parallel (1.20.1) - parser (3.0.1.1) + parallel (1.27.0) + parser (3.3.10.0) ast (~> 2.4.1) - public_suffix (4.0.6) - rack (2.2.3) - rack-protection (2.1.0) - rack - rack-test (1.1.0) - rack (>= 1.0, < 3) - rainbow (3.0.0) - regexp_parser (2.1.1) - rexml (3.2.5) - rspec (3.10.0) - rspec-core (~> 3.10.0) - rspec-expectations (~> 3.10.0) - rspec-mocks (~> 3.10.0) - rspec-core (3.10.1) - rspec-support (~> 3.10.0) - rspec-expectations (3.10.1) + racc + prism (1.6.0) + public_suffix (7.0.0) + racc (1.8.1) + rack (2.2.21) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-test (2.2.0) + rack (>= 1.3) + rackup (1.0.1) + rack (< 3) + webrick + rainbow (3.1.1) + regexp_parser (2.11.3) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-mocks (3.10.2) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-support (3.10.2) - rubocop (1.14.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + rubocop (1.81.7) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) - parser (>= 3.0.0.0) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml - rubocop-ast (>= 1.5.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.5.0) - parser (>= 3.0.1.1) - ruby-prof (1.4.3) - ruby-progressbar (1.11.0) - ruby2_keywords (0.0.4) - rubyzip (2.3.0) - simplecov (0.21.2) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + ruby-prof (1.7.2) + base64 + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) - simplecov_json_formatter (0.1.3) - sinatra (2.1.0) - mustermann (~> 1.0) - rack (~> 2.2) - rack-protection (= 2.1.0) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) tilt (~> 2.0) - sinatra-contrib (2.1.0) - multi_json - mustermann (~> 1.0) - rack-protection (= 2.1.0) - sinatra (= 2.1.0) + sinatra-contrib (3.2.0) + multi_json (>= 0.0.2) + mustermann (~> 3.0) + rack-protection (= 3.2.0) + sinatra (= 3.2.0) tilt (~> 2.0) sinatra-websocket (0.3.1) em-websocket (~> 0.3.6) eventmachine thin (>= 1.3.1, < 2.0.0) systemu (2.6.5) - thin (1.8.0) + thin (1.8.2) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) - tilt (2.0.10) - unicode-display_width (2.0.0) + tilt (2.6.1) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) uuid (2.3.9) macaddr (~> 1.0) - zip_tricks (5.5.0) + webrick (1.9.2) + zip_tricks (5.6.0) PLATFORMS + arm64-darwin-23 ruby DEPENDENCIES - ffaker - mocha - rack-test - rspec - rubocop - ruby-prof - rubyzip - simplecov - sinatra - sinatra-contrib - sinatra-websocket - uuid - zip_tricks + ffaker (~> 2.23) + mocha (~> 2.7) + rack-test (~> 2.2) + rackup (~> 1.0) + rspec (~> 3.13) + rubocop (~> 1.69) + ruby-prof (~> 1.7) + rubyzip (~> 2.4) + simplecov (~> 0.22) + sinatra (~> 3.2) + sinatra-contrib (~> 3.2) + sinatra-websocket (~> 0.3.1) + thin (~> 1.8) + uuid (~> 2.3) + zip_tricks (~> 5.6) + +RUBY VERSION + ruby 3.3.6p108 BUNDLED WITH - 2.1.4 + 2.5.22 diff --git a/app.rb b/app.rb index 4a5b5b3..6bb61fa 100644 --- a/app.rb +++ b/app.rb @@ -12,24 +12,118 @@ class App < Sinatra::Base set :static, true set :registry, DLCenter::Registry.new + # Rate limiting: max connections per IP + set :max_connections_per_ip, Integer(ENV.fetch('DLCENTER_MAX_CONNECTIONS_PER_IP', 50)) + set :connection_counts, Hash.new(0) + + # Security headers + before do + headers \ + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'DENY', + 'X-XSS-Protection' => '1; mode=block', + 'Referrer-Policy' => 'strict-origin-when-cross-origin' + + # HTTPS enforcement (when behind proxy with X-Forwarded-Proto) + if ENV['DLCENTER_FORCE_HTTPS'] && request.env['HTTP_X_FORWARDED_PROTO'] == 'http' + redirect "https://#{request.host}#{request.fullpath}", 301 + end + end + + # CSRF protection helper + helpers do + def sanitize_filename(filename) + return 'download' if filename.nil? || filename.empty? + # Remove control characters, quotes, and path separators + filename.gsub(/[\x00-\x1f\x7f"\\\/\r\n]/, '_').strip[0, 255] + end + + def valid_content_type?(content_type) + return false if content_type.nil? + # Allow only safe content types, default to octet-stream + content_type.match?(/\A[\w\-]+\/[\w\-\.\+]+\z/) && content_type.length < 256 + end + + def safe_content_type(content_type) + valid_content_type?(content_type) ? content_type : 'application/octet-stream' + end + + def check_rate_limit(ip) + count = settings.connection_counts[ip] + if count >= settings.max_connections_per_ip + halt 429, 'Too many connections' + end + settings.connection_counts[ip] += 1 + end + + def release_rate_limit(ip) + settings.connection_counts[ip] -= 1 + settings.connection_counts.delete(ip) if settings.connection_counts[ip] <= 0 + end + + def check_csrf + # CSRF protection: verify Origin/Referer for state-changing requests + origin = request.env['HTTP_ORIGIN'] + referer = request.env['HTTP_REFERER'] + + # Allow requests with no Origin (same-origin requests from some browsers) + return if origin.nil? && referer.nil? + + host = request.host + port = request.port + + # Check Origin header + if origin + origin_uri = URI.parse(origin) rescue nil + if origin_uri + origin_host = origin_uri.host + origin_port = origin_uri.port + return if origin_host == host && (origin_port == port || [80, 443].include?(origin_port)) + end + end + + # Check Referer header as fallback + if referer + referer_uri = URI.parse(referer) rescue nil + if referer_uri + referer_host = referer_uri.host + return if referer_host == host + end + end + + halt 403, 'CSRF check failed' + end + end + get '/ws' do + check_rate_limit(request.ip) begin request.websocket do |ws| namespace = namespace_for_request(request) client = WSClient.new namespace, ws namespace.add_client client + ws.onclose do + release_rate_limit(request.ip) + end end rescue SinatraWebsocket::Error::ConnectionError + release_rate_limit(request.ip) puts "Not a websocket" end end post '/p/:filename' do + check_csrf + check_rate_limit(request.ip) stream(:keep_open) do |out| namespace = namespace_for_request(request) - client = IOClient.new namespace, request.env['data.input'], out, filename: params[:filename], size: request.env["CONTENT_LENGTH"], content_type: request.env["CONTENT_TYPE"] + client = IOClient.new namespace, request.env['data.input'], out, + filename: sanitize_filename(params[:filename]), + size: request.env["CONTENT_LENGTH"], + content_type: safe_content_type(request.env["CONTENT_TYPE"]) namespace.add_client client namespace.broadcast_available_shares + release_rate_limit(request.ip) end end @@ -49,8 +143,8 @@ def namespace_for_request(request) options = { "Cache-Control" => "no-cache, private", "Pragma" => "no-cache", - "Content-type" => "#{share.content_type || "octet/stream"}", - "Content-Disposition" => "attachment; filename=\"#{share.name}\"" + "Content-type" => safe_content_type(share.content_type), + "Content-Disposition" => "attachment; filename=\"#{sanitize_filename(share.name)}\"" } options["Content-Length"] = "#{share.size}" unless share.size.nil? headers options @@ -83,15 +177,16 @@ def namespace_for_request(request) end get '/share/:uuid' do uuid = params[:uuid] - puts "Lookup file #{uuid}" + # Validate UUID format to prevent log injection + halt 400, 'Invalid UUID' unless uuid.match?(/\A[a-f0-9\-]{36}\z/i) share = settings.registry.get_share_by_uuid uuid if share headers \ "Cache-Control" => "no-cache, private", "Pragma" => "no-cache", - "Content-type" => "#{share.content_type}", + "Content-type" => safe_content_type(share.content_type), "Content-Length" => "#{share.size}", - "Content-Disposition" => "attachment; filename=\"#{share.name}\"" + "Content-Disposition" => "attachment; filename=\"#{sanitize_filename(share.name)}\"" stream(:keep_open) do |out| share.content(out) diff --git a/conductor.json b/conductor.json new file mode 100644 index 0000000..93956ff --- /dev/null +++ b/conductor.json @@ -0,0 +1,6 @@ +{ + "scripts": { + "setup": "bundle install", + "run": "bundle exec rackup -p $CONDUCTOR_PORT" + } +} diff --git a/docker-compose.beta.yml b/docker-compose.beta.yml index 8b2cced..39d5421 100644 --- a/docker-compose.beta.yml +++ b/docker-compose.beta.yml @@ -1,5 +1,6 @@ -app: - restart: unless-stopped - build: . - environment: - VIRTUAL_HOST: share.localhost,beta.dl.center +services: + app: + restart: unless-stopped + build: . + environment: + VIRTUAL_HOST: share.localhost,beta.dl.center diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 97eef97..07fcb9a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,8 +1,9 @@ -app: - build: . - volumes: - - ./public:/app/public - environment: - VIRTUAL_HOST: dl.localhost,dl.center - LETSENCRYPT_EMAIL: letsencrypt@simkim.net - LETSENCRYPT_HOST: dl.center +services: + app: + build: . + volumes: + - ./public:/app/public + environment: + VIRTUAL_HOST: dl.localhost,dl.center + LETSENCRYPT_EMAIL: letsencrypt@simkim.net + LETSENCRYPT_HOST: dl.center diff --git a/docker-compose.yml b/docker-compose.yml index 87ac248..f158801 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,9 @@ -app: - restart: unless-stopped - build: . - environment: - RACK_ENV: production - VIRTUAL_HOST: dl.localhost,dl.center - LETSENCRYPT_EMAIL: letsencrypt@simkim.net - LETSENCRYPT_HOST: dl.center +services: + app: + restart: unless-stopped + build: . + environment: + RACK_ENV: production + VIRTUAL_HOST: dl.localhost,dl.center + LETSENCRYPT_EMAIL: letsencrypt@simkim.net + LETSENCRYPT_HOST: dl.center diff --git a/lib/dlcenter/client.rb b/lib/dlcenter/client.rb index ec2b1cd..9067746 100644 --- a/lib/dlcenter/client.rb +++ b/lib/dlcenter/client.rb @@ -1,6 +1,13 @@ require 'base64' module DLCenter + # Input validation constants + MAX_FILENAME_LENGTH = 255 + MAX_CONTENT_TYPE_LENGTH = 256 + MAX_INLINE_CONTENT_LENGTH = 10_000 + MAX_SHARES_PER_CLIENT = 100 + MAX_CHUNK_SIZE = 2 * 1024 * 1024 # 2MB max chunk size + class Client attr_reader :shares attr_accessor :namespace @@ -121,9 +128,62 @@ def send(ws_msg) end def handle_register_share(msg) + # Validate share count limit + if @shares.size >= MAX_SHARES_PER_CLIENT + puts "Client exceeded max shares limit" + return + end + + # Validate and sanitize name name = msg[:name] - puts "Websocket #{@ws} share file #{name}" - share = Share.new(self, msg) + unless name.is_a?(String) && name.length > 0 && name.length <= MAX_FILENAME_LENGTH + puts "Invalid share name" + return + end + # Sanitize filename: remove control characters, path separators + sanitized_name = name.gsub(/[\x00-\x1f\x7f"\\\/\r\n]/, '_').strip + + # Validate content_type + content_type = msg[:content_type] + if content_type + unless content_type.is_a?(String) && content_type.length <= MAX_CONTENT_TYPE_LENGTH && + content_type.match?(/\A[\w\-]+\/[\w\-\.\+]+\z/) + content_type = 'application/octet-stream' + end + end + + # Validate size + size = msg[:size] + if size && (!size.is_a?(Integer) || size < 0) + size = nil + end + + # Validate inline content (for links/text shares) + inline_content = msg[:content] + if inline_content + unless inline_content.is_a?(String) && inline_content.length <= MAX_INLINE_CONTENT_LENGTH + inline_content = nil + end + end + + # Validate client-provided UUID format + uuid = msg[:uuid] + if uuid.is_a?(String) && uuid.match?(/\A[a-f0-9\-]{36}\z/i) + sanitized_uuid = uuid + else + sanitized_uuid = nil # Let Share generate one + end + + sanitized_msg = { + uuid: sanitized_uuid, + name: sanitized_name, + content_type: content_type, + size: size, + content: inline_content, + oneshot: msg[:oneshot] == true + } + + share = Share.new(self, sanitized_msg) self.add_share(share) @namespace.broadcast_available_shares return @@ -131,17 +191,37 @@ def handle_register_share(msg) def handle_unregister_share(msg) uuid = msg[:uuid] + # Validate UUID format + unless uuid.is_a?(String) && uuid.match?(/\A[a-f0-9\-]{36}\z/i) + puts "Invalid UUID format" + return + end self.remove_share_by_uuid(uuid) @namespace.broadcast_available_shares end def handle_chunk(msg) uuid = msg[:uuid] + # Validate UUID format + unless uuid.is_a?(String) && uuid.match?(/\A[a-f0-9\-]{36}\z/i) + puts "Invalid UUID format in chunk" + return false + end + encoded_chunk = msg[:chunk] + unless encoded_chunk.is_a?(String) + puts "Invalid chunk data" + return false + end + stream = @streams[uuid] if stream chunk = Base64.decode64(encoded_chunk) - # puts "Got chunk of size #{chunk.length} for stream #{uuid}" + # Validate chunk size + if chunk.length > MAX_CHUNK_SIZE + puts "Chunk too large: #{chunk.length} bytes" + return false + end stream.got_chunk(chunk) begin stream.drain_buffer diff --git a/lib/dlcenter/share.rb b/lib/dlcenter/share.rb index 2c5477d..0aa5f22 100644 --- a/lib/dlcenter/share.rb +++ b/lib/dlcenter/share.rb @@ -9,8 +9,7 @@ class Share def initialize client, options = {} @client = client - @uuid = options[:uuid] - @uuid ||= SecureRandom.uuid + @uuid = options[:uuid] || SecureRandom.uuid raise "Invalid option : #{options.class} #{options}" unless options.class == Hash self.content_type = options[:content_type] self.name = options[:name] @@ -24,7 +23,9 @@ def self.content(shares, out) w = ZipTricks::BlockWrite.new { |chunk| out.write(chunk) } ZipTricks::Streamer.open(w) do |zip| shares.each do |share| - zip.write_deflated_file(share.name) do |sink| + # Sanitize filename to prevent zip path traversal + safe_name = sanitize_zip_filename(share.name) + zip.write_deflated_file(safe_name) do |sink| r, w = IO.pipe share.content(w) while true @@ -39,6 +40,15 @@ def self.content(shares, out) out.close end + def self.sanitize_zip_filename(name) + return 'file' if name.nil? || name.empty? + # Remove path traversal sequences and leading slashes + safe = name.gsub(/\.\./, '_').gsub(/^\/+/, '').gsub(/\\/, '_') + # Remove any remaining absolute path indicators + safe = safe.sub(/^[A-Za-z]:/, '') + safe.empty? ? 'file' : safe + end + def content(out) Streamer.new(self, out).tap do |stream| client.ask_for_stream(stream) diff --git a/lib/dlcenter/streamer.rb b/lib/dlcenter/streamer.rb index f7baa29..5401340 100644 --- a/lib/dlcenter/streamer.rb +++ b/lib/dlcenter/streamer.rb @@ -1,16 +1,30 @@ module DLCenter class Streamer + # Max buffer size: 10MB - prevents memory exhaustion + MAX_BUFFER_SIZE = 10 * 1024 * 1024 + attr_reader :share, :buffer, :uuid, :out def initialize(share, out) @uuid = SecureRandom.uuid @share = share @out = out @buffer = "" + @closed = false end + def got_chunk(chunk) + return if @closed + # Check buffer size limit + if @buffer.bytesize + chunk.bytesize > MAX_BUFFER_SIZE + puts "Buffer overflow prevented for stream #{@uuid}" + close + raise IOError, "Buffer size exceeded" + end @buffer += chunk end + def drain_buffer + return if @closed buffer = @buffer @buffer = "" EM.next_tick { @@ -21,7 +35,10 @@ def drain_buffer end } end + def close + return if @closed + @closed = true EM.next_tick { @out.close } diff --git a/public/css/main.css b/public/css/main.css index 875776f..dc6389e 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1,294 +1,509 @@ -@import url('https://fonts.googleapis.com/css2?family=Lato:wght@400;900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +:root { + --theme-color: #3b82f6; + --theme-color-dark: #2563eb; + --green: #10b981; + --green-dark: #059669; + --red: #ef4444; + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-300: #d1d5db; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-600: #4b5563; + --gray-700: #374151; + --gray-800: #1f2937; + --gray-900: #111827; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --radius: 0.75rem; + --radius-sm: 0.5rem; +} + +* { + box-sizing: border-box; +} html { - background: rgb(78, 78, 87) url(https://source.unsplash.com/featured?devices) top/cover no-repeat; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-attachment: fixed; + min-height: 100vh; color: #fff; - - font-family: 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif; - - --theme-color: rgb(53, 166, 218); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + -webkit-font-smoothing: antialiased; } @media (max-width: 700px) { html { - background: #fff; + background: var(--gray-100); } } - body { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; - box-sizing: border-box; + margin: 0; + padding: 1rem; } @media (max-width: 700px) { body { - justify-content: start; + justify-content: flex-start; + padding: 0; } } +a { + color: var(--theme-color); + text-decoration: none; + transition: color 0.15s ease; +} a:visited { color: var(--theme-color); } -a { - color: var(--theme-color); +a:hover, +a:visited:hover { + color: var(--theme-color-dark); + text-decoration: underline; } .dropzone--dropping { - box-shadow: inset 0 0 1rem 1rem var(--theme-color); + box-shadow: inset 0 0 0 4px var(--theme-color); } - .main-view { - max-width: 80ex; - - background: hsla(0, 100%, 100%, .97); - color: hsl(220, 6%, 10%); - border: 1px solid; - border-color: hsla(0, 100%, 100%, .2) hsla(0, 0%, 0%, .2) hsla(0, 0%, 0%, .2) hsla(0, 100%, 100%, .2); - - /* -webkit-backdrop-filter: blur(20px); - backdrop-filter: blur(20px); */ - box-shadow: - 0 1rem 1rem hsla(0, 0%, 0%, .5), - 0 1rem 3rem hsla(0, 0%, 0%, .5); - - border-radius: 1rem; - padding: 1rem; - margin: 1rem 1rem 3rem; + width: 100%; + max-width: 640px; + background: white; + color: var(--gray-800); + border-radius: var(--radius); + padding: 1.5rem; + box-shadow: var(--shadow-xl); } @media (max-width: 700px) { .main-view { border-radius: 0; - border: 0; box-shadow: none; + min-height: 100vh; } } +.header { + margin-bottom: 1.5rem; +} + .header__title { - margin: 0; + margin: 0 0 0.25rem 0; + font-size: 2rem; + font-weight: 700; color: var(--theme-color); - line-height: 0.7; + letter-spacing: -0.025em; } .header__subtitle { - color: var(--theme-color); + color: var(--gray-500); + font-size: 0.95rem; } - .error-message { margin: 1rem 0; padding: 1rem; - border-radius: 1rem; - - border: 1px solid rgba(156, 20, 20, 0.658); - background: rgba(255, 0, 0, 0.13); + border-radius: var(--radius-sm); + border: 1px solid #fecaca; + background: #fef2f2; + color: var(--red); + font-size: 0.9rem; } .shares { display: flex; flex-direction: column; - align-items: stretch; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.shares h2 { + font-size: 1.1rem; + font-weight: 600; + color: var(--gray-700); + margin: 0 0 0.75rem 0; } .shares__share { - background: #fff; - border-radius: .7rem; - border: 1px solid hsla(0, 0%, 0%, .2); - padding: .5rem; - margin-bottom: .4rem; + background: var(--gray-50); + border-radius: var(--radius-sm); + border: 1px solid var(--gray-200); + padding: 0.75rem 1rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.shares__share:hover { + border-color: var(--gray-300); + box-shadow: var(--shadow-sm); } .shares__download-all { align-self: flex-end; + margin-top: 0.5rem; } .share { display: flex; align-items: center; + gap: 0.75rem; } @media (max-width: 500px) { .share { flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .share__download { + width: 100%; + justify-content: center; } } .share__remove { - margin: 0 .5rem 0 .3rem; - color: rgb(194, 18, 18); + color: var(--red); cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s ease; + flex-shrink: 0; +} + +.share__remove:hover { + opacity: 1; } .share__empty { justify-content: center; - color: hsla(0, 0%, 0%, .5); + color: var(--gray-400); + font-size: 0.9rem; + padding: 1.5rem; } .share__name { margin: 0; - font-size: 100%; - margin-right: .5rem; + font-size: 0.95rem; + font-weight: 500; + color: var(--gray-800); + word-break: break-word; +} + +.share__link a { + color: var(--theme-color); } .share__filesize { flex-grow: 1; - color: hsla(0, 0%, 0%, .5); + color: var(--gray-400); + font-size: 0.85rem; white-space: nowrap; - margin-right: .5rem; } .share__qrcode { - margin-right: .5rem; - color: hsla(0, 0%, 0%, .5); + color: var(--gray-400); cursor: pointer; + padding: 0.25rem; + transition: color 0.15s ease; + flex-shrink: 0; } - - -.uploads { - display: flex; - flex-direction: row; - flex-flow: row wrap; +.share__qrcode:hover { + color: var(--gray-600); } -.uploads__upload { - flex: 1 1 50%; +.uploads { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + margin-bottom: 1.5rem; } @media (max-width: 500px) { .uploads { - flex-direction: column; + grid-template-columns: 1fr; } } +.uploads__upload h2 { + font-size: 1rem; + font-weight: 600; + color: var(--gray-700); + margin: 0 0 0.75rem 0; +} + +.uploads__upload p { + margin: 0 0 0.75rem 0; + color: var(--gray-500); + font-size: 0.85rem; + line-height: 1.5; +} .file-upload__input { visibility: hidden; position: absolute; + width: 0; + height: 0; } .file-upload__button { cursor: pointer; + display: inline-flex; } - .text-upload__text { width: 100%; + padding: 0.75rem; + border: 1px solid var(--gray-300); + border-radius: var(--radius-sm); + font-family: inherit; + font-size: 0.9rem; + resize: vertical; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.text-upload__text:focus { + outline: none; + border-color: var(--theme-color); + box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1); } .text-upload__beta { - font-weight: normal; - font-size: 80%; - opacity: .6; + font-weight: 400; + font-size: 0.7rem; + color: var(--gray-400); vertical-align: super; + margin-left: 0.25rem; +} + +.howto { + background: var(--gray-50); + border-radius: var(--radius-sm); + padding: 1rem; + margin-bottom: 1.5rem; } .howto__header { - margin-bottom: 0; + font-size: 1rem; + font-weight: 600; + color: var(--gray-700); + margin: 0 0 0.5rem 0; +} + +.howto p { + margin: 0; + color: var(--gray-600); + font-size: 0.9rem; + line-height: 1.6; } .made-with { - color: var(--theme-color); - line-height: 0.7; + text-align: center; + color: var(--gray-400); + font-size: 0.85rem; + padding-top: 1rem; + border-top: 1px solid var(--gray-200); + line-height: 1.6; +} - border-top: 1px solid var(--theme-color); - padding-top: .5rem; +.made-with a, +.made-with a:visited { + color: var(--gray-500); + font-weight: 500; } +.made-with a:hover, +.made-with a:visited:hover { + color: var(--theme-color); +} .qrcode-modal { position: fixed; - top: 5vh; - left: 5vw; - right: 5vw; - bottom: 5vh; + top: 0; + left: 0; + right: 0; + bottom: 0; z-index: -1; - opacity: 0; - transform: scale(0.05); - - transition-property: transform, opacity; - transition-duration: 500ms; - will-change: transform, opacity; - display: flex; - flex-direction: column; - - background: #fff; + align-items: center; + justify-content: center; + background: rgba(17, 24, 39, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); padding: 1rem; - border-radius: 1rem; - box-sizing: border-box; - box-shadow: 0 1rem 3rem hsla(0, 0%, 0%, .3); - - cursor: pointer; + transition: opacity 0.25s ease; } .qrcode-modal--visible { - z-index: 1; + z-index: 100; opacity: 1; - transform: none; } -.qrcode-modal__header { - margin: 0 0 1rem 0; +.qrcode-modal__content { + position: relative; + background: white; + border-radius: 1.25rem; + padding: 2rem 2.5rem 2.5rem; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.05), + 0 25px 50px -12px rgba(0, 0, 0, 0.4); text-align: center; + max-width: 90vw; + animation: modalSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); } -.qrcode-modal__image, -.qrcode-modal__image canvas { - flex-grow: 1; +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.qrcode-modal__close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + width: 2rem; + height: 2rem; + border: none; + background: transparent; + border-radius: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--gray-400); + transition: background-color 0.15s ease, color 0.15s ease; +} + +.qrcode-modal__close:hover { + background: var(--gray-100); + color: var(--gray-600); +} + +.qrcode-modal__image { display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: white; + border-radius: 0.75rem; + border: 1px solid var(--gray-100); + margin-bottom: 1.25rem; +} + +.qrcode-modal__image img { + display: block; image-rendering: pixelated; image-rendering: crisp-edges; - object-fit: contain; - min-width: 1px; - min-height: 1px; - max-width: 100%; - max-height: 100%; + width: 280px; + height: 280px; } +.qrcode-modal__filename { + font-size: 0.9375rem; + font-weight: 600; + color: var(--gray-900); + margin-bottom: 0.25rem; + word-break: break-word; + max-width: 280px; + margin-left: auto; + margin-right: auto; + line-height: 1.4; +} + +.qrcode-modal__hint { + font-size: 0.8125rem; + color: var(--gray-400); + font-weight: 500; +} .button { - background-color: #000; - color: #fff; - border-radius: .5rem; - padding: .5rem .7rem; - border: 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background-color: var(--gray-800); + color: white; + border-radius: var(--radius-sm); + padding: 0.625rem 1rem; + border: none; + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; text-decoration: none; white-space: nowrap; + cursor: pointer; + transition: background-color 0.15s ease, transform 0.1s ease; +} + +.button:hover { + text-decoration: none; +} + +.button:active { + transform: scale(0.98); } .button__icon { - margin-right: .5rem; + display: inline-flex; +} + +.button--green, +.button--green:visited { + background-color: var(--green); + color: white; } -.button--green { - background-color: #04A777; +.button--green:hover, +.button--green:visited:hover { + background-color: var(--green-dark); + color: white; } -.button--blue { - background-color: #5DA9E9; +.button--blue, +.button--blue:visited { + background-color: var(--theme-color); + color: white; } +.button--blue:hover, +.button--blue:visited:hover { + background-color: var(--theme-color-dark); + color: white; +} +/* Icon font */ @font-face { font-family: icons; - src: url("/css/fonts/icons.ttf") format("ttf"), - url("/css/fonts/icons.woff2") format("woff2"), - url("/css/fonts/icons.woff") format("woff"); + src: url("/css/fonts/icons.woff2") format("woff2"), + url("/css/fonts/icons.woff") format("woff"), + url("/css/fonts/icons.ttf") format("truetype"); } .icon::after { font-family: icons; + font-style: normal; } .icon--plus::after { @@ -297,6 +512,7 @@ a { .icon--heart::after { content: "\f004"; + color: #ef4444; } .icon--qrcode::after { @@ -309,4 +525,4 @@ a { .icon--times::after { content: "\f00d"; -} \ No newline at end of file +} diff --git a/public/files.html b/public/files.html deleted file mode 100644 index 17c6bea..0000000 --- a/public/files.html +++ /dev/null @@ -1,106 +0,0 @@ -
-

- DL.center -

-
- Easily transfer files between nearby devices -
-
- -
- Disconnected, trying to reconnect... -
- -
-

Shared files

-
- No shared files yet -
-
- - - - - - - -
- - - Download All - -
- - -
-
-

- Share a file -

-

- -

-

- You can also drag and drop from your computer here. -

-
- -
-

- Share text - beta -

-

- -

-

- -

-
-
- -
-

- How to use ? -

-

- Share a file, stay on this page. - Open dl.center on another device - using the same Internet connection. Click on download to retrieve your file! -

-
- -
- Made with - - by - @gmonserand - and - @genezys -
\ No newline at end of file diff --git a/public/index.html b/public/index.html index 6c7ff6a..a493014 100644 --- a/public/index.html +++ b/public/index.html @@ -1,13 +1,11 @@ - + - - @@ -17,22 +15,30 @@ - dl.center - Transfer files between nearby devices - - - + + + + + + + + - - - + + + -
+
+ + + + - \ No newline at end of file + diff --git a/public/js/app.js b/public/js/app.js index cffe664..6ed2501 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,303 +1,265 @@ -'use strict'; +const { useState, useEffect, useRef, useCallback } = React; + +function App() { + const [connected, setConnected] = useState(false); + const [remoteShares, setRemoteShares] = useState([]); + const [localShares, setLocalShares] = useState({}); + const [qrModal, setQrModal] = useState(null); + const [textValue, setTextValue] = useState(''); + const wsRef = useRef(null); + const pingRef = useRef(null); + // Keep a synchronous ref of local shares for streaming lookups + const localSharesRef = useRef({}); + const downloadHost = `${document.location.protocol}//${document.location.host}`; + + const addFileShare = useCallback((file) => { + if (file.size >= 5000 * 1024 * 1024) { + console.error("File size too high: " + file.size); + alert("File size too high: " + file.size); + return; + } -if (window.File && window.FileReader && window.FileList && window.Blob) { - // Great success! All the File APIs are supported. -} else { - alert('The File APIs are not fully supported in this browser.'); -} + const uuid = generateUUID(); + console.log("add file to store, uuid:", uuid); -function setupWS($scope, $timeout, $interval) { - var protocol = ""; - if (document.location.protocol === "https:") { - protocol = "wss:"; - } else { - protocol = "ws:"; - } - var ws = new WebSocket(protocol + '//' + window.location.host + "/ws"); - - ws.onopen = function () { - $scope.connected = true; - console.log('websocket opened'); - $scope.ping = $interval(function () { - ws.send(JSON.stringify({ - type: "ping", - })); - }, 10000); + const share = { + name: file.name, + size: file.size, + type: file.type, + uuid: uuid, + file: file, }; - ws.onclose = function () { - console.log("Websocket closed"); - if ($scope.connected) { - $scope.connected = false; - $scope.clients = {}; - $interval.cancel($scope.ping); - $timeout(function () { - setupWS($scope, $timeout, $interval); - }, 2000); - } - }; - ws.onerror = function () { - console.log("Websocket error"); - $scope.connected = false; - $scope.clients = {}; - $timeout(function () { - setupWS($scope, $timeout, $interval); - }, 10000); + // Update ref synchronously before sending WebSocket message + localSharesRef.current[uuid] = share; + setLocalShares(prev => ({ ...prev, [uuid]: share })); + + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: "register_share", + uuid: uuid, + name: file.name, + content_type: file.type, + size: file.size + })); } - ws.onmessage = function (m) { - console.log('websocket message: ' + m.data); - var msg = JSON.parse(m.data); - $scope.$apply(function () { - $scope.handle_msg(msg); - }) + }, []); + + const addContentShare = useCallback((content) => { + const uuid = generateUUID(); + console.log("add content to store, uuid:", uuid); + + const share = { + name: "clipboard", + size: content.length, + type: "text/plain", + uuid: uuid, + content: content, }; - window.ws = ws; -} + // Update ref synchronously before sending WebSocket message + localSharesRef.current[uuid] = share; + setLocalShares(prev => ({ ...prev, [uuid]: share })); -function addSharesToStore($scope, files) { - for (var i = 0, file; file = files[i]; i++) { - if (file.size < 5000 * 1024 * 1024) { - addShareToStore($scope, file); - } else { - console.error("File size to high : " + file.size); - alert("File size to high : " + file.size); - } + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: "register_share", + uuid: uuid, + name: ellipseAt(content, 100), + content: content, + content_type: "text/plain", + size: content.length + })); } -} - -function generateUUID() { - var d = new Date().getTime(); - var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - var r = (d + Math.random() * 16) % 16 | 0; - d = Math.floor(d / 16); - return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }, []); + + const removeShare = useCallback((share) => { + // Update ref synchronously + delete localSharesRef.current[share.uuid]; + setLocalShares(prev => { + const newShares = { ...prev }; + delete newShares[share.uuid]; + return newShares; }); - return uuid; -}; -function ellipseAt(str, length) { - if (str.length > length) { - return str.substring(0, length) + "..." + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: "unregister_share", + uuid: share.uuid + })); } - return str -} + }, []); + + const handleStream = useCallback((msg) => { + console.log("Should stream file " + msg.share + " to stream " + msg.uuid); + // Use ref for synchronous lookup + const share = localSharesRef.current[msg.share]; + if (share) { + streamShare(share, msg.uuid, wsRef.current); + } else { + console.log("can't find share " + msg.share + " in shares", localSharesRef.current); + } + }, []); + + const setupWebSocket = useCallback(() => { + const protocol = document.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(protocol + '//' + window.location.host + "/ws"); + wsRef.current = ws; + + ws.onopen = () => { + setConnected(true); + console.log('websocket opened'); + pingRef.current = setInterval(() => { + ws.send(JSON.stringify({ type: "ping" })); + }, 10000); + }; -function addContentShareToStore($scope, content) { - console.log("add content to store"); - var uuid = generateUUID(); - console.log("size : " + content.length); - console.log("uuid : " + uuid); + ws.onclose = () => { + console.log("Websocket closed"); + setConnected(false); + setRemoteShares([]); + if (pingRef.current) { + clearInterval(pingRef.current); + } + setTimeout(setupWebSocket, 2000); + }; - $scope.shares[uuid] = { - name: "clipboard", - size: content.length, - type: "text/plain", - content: content, + ws.onerror = () => { + console.log("Websocket error"); + setConnected(false); + setRemoteShares([]); + setTimeout(setupWebSocket, 10000); }; - var fileRegister = JSON.stringify({ - type: "register_share", - uuid: uuid, - name: ellipseAt(content, 100), - content: content, - content_type: "text/plain", - size: content.length - }); - ws.send(fileRegister); -}; -function addShareToStore($scope, file) { - console.log("add file to store"); - var uuid = generateUUID(); - console.log("size : " + file.size); - console.log("uuid : " + uuid); - - $scope.shares[uuid] = { - name: file.name, - size: file.size, - type: file.type, - uuid: uuid, - file: file, + ws.onmessage = (m) => { + console.log('websocket message: ' + m.data); + const msg = JSON.parse(m.data); + switch (msg.type) { + case "shares": + setRemoteShares(msg.shares); + break; + case "hello": + console.log("Hello: " + msg.text); + break; + case "stream": + handleStream(msg); + break; + default: + console.error("Unknown message: " + msg.type); + } }; + }, [handleStream]); + + useEffect(() => { + setupWebSocket(); + return () => { + if (wsRef.current) { + wsRef.current.close(); + } + if (pingRef.current) { + clearInterval(pingRef.current); + } + }; + }, [setupWebSocket]); - var fileRegister = JSON.stringify({ - type: "register_share", - uuid: uuid, - name: file.name, - content_type: file.type, - size: file.size - }); - ws.send(fileRegister); -}; + useEffect(() => { + const dropZone = document.querySelector('.dropzone'); -function removeShare($scope, share) { - delete $scope.shares[share.uuid]; - var fileUnregister = JSON.stringify({ - type: "unregister_share", - uuid: share.uuid - }); - ws.send(fileUnregister); -}; - -function streamChunk(share, stream_uuid, start, length, cb) { - var reader = new FileReader(); - reader.onload = function (e) { - // console.log("chunk loaded " + stream_uuid + " ("+start+","+length+")"); - if (length == 0) { - console.error("can't stream chunk of length 0"); - return; + const handleDragOver = (e) => { + e.stopPropagation(); + e.preventDefault(); + dropZone.classList.add("dropzone--dropping"); + e.dataTransfer.dropEffect = 'copy'; + }; + + const handleDragEnter = (e) => { + dropZone.classList.add("dropzone--dropping"); + return false; + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.remove("dropzone--dropping"); + return false; + }; + + const handleDrop = (e) => { + e.stopPropagation(); + e.preventDefault(); + dropZone.classList.remove("dropzone--dropping"); + const files = e.dataTransfer.files; + if (files.length > 0) { + for (let i = 0; i < files.length; i++) { + addFileShare(files[i]); } - if (length != e.target.result.length) { - console.error("Ask for " + length + " but got ", e.target.result.length); - return; + } else { + const text = e.dataTransfer.getData("Text"); + if (text) { + addContentShare(text); } - var close = (start + length) == share.file.size; - ws.send(JSON.stringify({ - type: "chunk", - uuid: stream_uuid, - close: close, - chunk: btoa(e.target.result) - })); - if (cb) - cb(close); + } + return false; }; - var blob = share.file.slice(start, start + length); - reader.readAsBinaryString(blob); -}; - -function streamShare(share, stream_uuid, cb) { - console.log("stream share " + stream_uuid + " (" + share.size + ")"); - if (share.content) { - ws.send(JSON.stringify({ - type: "chunk", - uuid: stream_uuid, - close: true, - chunk: btoa(share.content) - })); - } else { - var position = 0; - function chunkStreamed(done) { - if (done) { - if (cb) - cb(); - return; - } - var start = position; - var length = Math.min(1024000, share.size - position); - position += length; - streamChunk(share, stream_uuid, start, length, chunkStreamed); - } - chunkStreamed(false); - } -}; - -function setupFileDrop($scope) { - var dropZone = document.querySelector('.dropzone'); - - // Optional. Show the copy icon when dragging over. Seems to only work for chrome. - dropZone.addEventListener('dragover', function (e) { - e.stopPropagation(); - e.preventDefault(); - $scope.dragdrop = true; - dropZone.classList.add("dropzone--dropping"); - e.dataTransfer.dropEffect = 'copy'; - }); - dropZone.addEventListener('dragenter', function (e) { - dropZone.classList.add("dropzone--dropping"); - return false; - }); + dropZone.addEventListener('dragover', handleDragOver); + dropZone.addEventListener('dragenter', handleDragEnter); + dropZone.addEventListener('dragleave', handleDragLeave); + dropZone.addEventListener('drop', handleDrop); - dropZone.addEventListener('dragleave', function (e) { - e.preventDefault(); - e.stopPropagation(); - dropZone.classList.remove("dropzone--dropping") - return false; - }); + return () => { + dropZone.removeEventListener('dragover', handleDragOver); + dropZone.removeEventListener('dragenter', handleDragEnter); + dropZone.removeEventListener('dragleave', handleDragLeave); + dropZone.removeEventListener('drop', handleDrop); + }; + }, [addFileShare, addContentShare]); - // Get file data on drop - dropZone.addEventListener('drop', function (e) { - e.stopPropagation(); - e.preventDefault(); - dropZone.classList.remove("dropzone--dropping") - var files = e.dataTransfer.files; // Array of all files - if (files.length > 0) { - addSharesToStore($scope, files); - } else { - addContentShareToStore($scope, e.dataTransfer.getData("Text")) - } - return false; - }); -}; - -var myApp = angular.module('shareApp', ['ui.router', 'monospaced.qrcode']); - -myApp.config(function ($stateProvider, $urlRouterProvider) { - - $urlRouterProvider.otherwise("/"); - - $stateProvider - .state('home', { - url: "/", - templateUrl: "files.html", - controller: function ($scope, $timeout, $interval) { - - $scope.remote_shares = []; - $scope.shares = {} - $scope.connected = false; - $scope.dragdrop = true; - $scope.downloadhost = document.location.protocol + "//" + document.location.host; - $scope.filesize = filesize; - function handle_stream(msg) { - console.log("Should stream file " + msg.share + " to stream " + msg.uuid) - var share = $scope.shares[msg.share]; - if (share) { - streamShare(share, msg.uuid); - } else { - console.log("cant find share " + msg.share + " in shares") - } - } - - function handle_shares(shares) { - console.log(shares); - $scope.remote_shares = shares; - }; - - $scope.handle_msg = function (msg) { - switch (msg.type) { - case "shares": handle_shares(msg.shares); break; - case "hello": console.log("Hello : " + msg.text); break; - case "stream": handle_stream(msg); break; - default: console.error("Unknown message" + msg.type); - } - }; - - $scope.remove_share = function (share) { - removeShare($scope, share); - } - - $scope.open_modal = function (uuid) { - document.querySelector("#modal-" + uuid).classList.add('qrcode-modal--visible'); - } - $scope.close_modal = function (uuid) { - document.querySelector("#modal-" + uuid).classList.remove('qrcode-modal--visible'); - } - - setupWS($scope, $timeout, $interval); - setupFileDrop($scope); - document.querySelector('.text-upload__button').addEventListener('click', function () { - const textarea = document.querySelector('.text-upload__text') - const text = textarea.value; - if (text.length > 0) { - addContentShareToStore($scope, text); - textarea.value = ''; - } - - }); - document.querySelector('.file-upload__input').addEventListener('change', function () { - addSharesToStore($scope, this.files); - this.value = null - }); - } - }); -}); + const handleTextShare = () => { + if (textValue.length > 0) { + addContentShare(textValue); + setTextValue(''); + } + }; + + return ( + <> +
+ + {!connected && ( +
Disconnected, trying to reconnect...
+ )} + + + + {qrModal && ( + setQrModal(null)} + /> + )} + +
+ + +
+ + +