diff --git a/README.md b/README.md index 2e0f14f..8bed588 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,6 @@ Simple file uploader made on Crystal. I'm making this to replace my current File uploader hosted on https://ayaya.beauty which uses https://github.com/nokonoko/uguu +## TODO + +- Add file size limit \ No newline at end of file diff --git a/spec/file-uploader_spec.cr b/spec/file-uploader_spec.cr new file mode 100644 index 0000000..5b98c28 --- /dev/null +++ b/spec/file-uploader_spec.cr @@ -0,0 +1,9 @@ +require "./spec_helper" + +describe File::Uploader do + # TODO: Write tests + + it "works" do + false.should eq(true) + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..38783b0 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/file-uploader" diff --git a/src/config.cr b/src/config.cr index b0b4f7a..5ed14fd 100644 --- a/src/config.cr +++ b/src/config.cr @@ -4,10 +4,14 @@ class Config include YAML::Serializable property files : String = "./files" + property db : String = "./db.sqlite3" property filename_lenght : Int8 = 3 property port : UInt16 = 8080 property unix_socket : String? property delete_files_after : Int32 = 7 + # How often should the check of old files be performed? (in seconds) + property delete_files_after_check_seconds : Int32 = 60 + property delete_key_lenght : Int8 = 8 def self.load config_file = "config/config.yml" @@ -15,4 +19,4 @@ class Config config = Config.from_yaml(config_yaml) config end -end \ No newline at end of file +end diff --git a/src/file-uploader.cr b/src/file-uploader.cr index 07c04e5..6c30bdd 100644 --- a/src/file-uploader.cr +++ b/src/file-uploader.cr @@ -1,24 +1,21 @@ require "http" require "kemal" require "yaml" -# require "mime" +require "db" +require "sqlite3" +require "digest" require "./utils" require "./handling" require "./lib/**" require "./config" -# macro error(message) -# env.response.content_type = "application/json" -# env.response.status_code = 403 -# error_message = {"error" => {{message}}}.to_json -# error_message -# end - CONFIG = Config.load Kemal.config.port = CONFIG.port +SQL = DB.open("sqlite3://#{CONFIG.db}") -Utils.create_files_directory +Utils.create_db +Utils.create_files_dir get "/" do |env| render "src/views/index.ecr" @@ -33,40 +30,20 @@ get "/:filename" do |env| Handling.retrieve_file(env) end +get "/delete" do |env| + Handling.delete_file(env) +end + get "/stats" do |env| Handling.stats(env) end -# TODO: HANDLE FILE DELETION WITH COOKIES - -# spawn do -# loop do -# begin -# Utils.check_old_files -# rescue ex -# puts "#{"[ERROR]".colorize(:red)} xd" -# end -# sleep 10 -# end -# end -# Fiber.yield - CHECK_OLD_FILES = Fiber.new do loop do Utils.check_old_files - sleep 1 + sleep CONFIG.delete_files_after_check_seconds end end CHECK_OLD_FILES.enqueue -# https://kemalcr.com/cookbook/unix_domain_socket/ -# Kemal.run do |config| -# if CONFIG.unix_socket != nil -# config.server.not_nil!.bind_unix(Socket::UNIXAddress.new(CONFIG.unix_socket)) -# else -# config.server.port = CONFIG.port -# end -# end Kemal.run - -# Fiber.yield diff --git a/src/handling.cr b/src/handling.cr new file mode 100644 index 0000000..57951e0 --- /dev/null +++ b/src/handling.cr @@ -0,0 +1,122 @@ +module Handling + extend self + + def upload(env) + filename = "" + extension = "" + original_filename = "" + uploaded_at = "" + file_hash = "" + ip_address = "" + delete_key = Random.base58(CONFIG.delete_key_lenght) + # TODO: Return the file that matches a checksum inside the database + HTTP::FormData.parse(env.request) do |upload| + next if upload.filename.nil? || upload.filename.to_s.empty? + extension = File.extname("#{upload.filename}") + filename = Random.base58(CONFIG.filename_lenght) + if !filename.is_a?(String) + return "This doesn't look like a file" + else + file_path = ::File.join ["#{CONFIG.files}", filename + extension] + File.open(file_path, "w") do |file| + IO.copy(upload.body, file) + end + + original_filename = upload.filename + uploaded_at = Time.utc + file_hash = Utils.hash_file(file_path) + ip_address = env.request.not_nil!.remote_address.to_s.split(":").first + SQL.exec "INSERT INTO FILES VALUES (?, ?, ?, ?, ?, ?, ?)", + original_filename, filename, extension, uploaded_at, file_hash, ip_address, delete_key + end + end + env.response.content_type = "application/json" + if !filename.empty? + JSON.build do |j| + j.object do + j.field "link", "https://#{env.request.headers["Host"]}/#{filename + extension}" + j.field "id", filename + j.field "ext", extension + j.field "name", original_filename + j.field "checksum", file_hash + j.field "deleteKey", delete_key + j.field "deleteLink", "https://#{env.request.headers["Host"]}/delete?key=#{delete_key}" + end + end + else + env.response.content_type = "application/json" + env.response.status_code = 403 + error_message = {"error" => "No file"}.to_json + error_message + end + end + + def retrieve_file(env) + begin + if !File.extname(env.params.url["filename"]).empty? + send_file env, "#{CONFIG.files}/#{env.params.url["filename"]}" + # next + end + dir = Dir.new("#{CONFIG.files}") + dir.each do |filename| + if filename.starts_with?("#{env.params.url["filename"]}") + send_file env, "#{CONFIG.files}/#{env.params.url["filename"]}" + File.extname(filename) + end + end + raise "" + rescue + env.response.content_type = "text/plain" + env.response.status_code = 403 + return "File does not exist" + end + end + + def stats(env) + begin + dir = Dir.new("#{CONFIG.files}") + rescue + env.response.content_type = "text/plain" + env.response.status_code = 403 + return "Unknown error" + end + + json_data = JSON.build do |json| + json.object do + json.field "stats" do + json.object do + begin + json.field "filesHosted", dir.children.size + rescue + json.field "filesHosted", 0 + end + end + end + end + end + dir.close + env.response.content_type = "application/json" + json_data + end + + def delete_file(env) + if SQL.query_one "SELECT EXISTS(SELECT 1 FROM files WHERE delete_key = ?)", env.params.query["key"], as: Bool + begin + file_to_delete = SQL.query_one "SELECT filename FROM files WHERE delete_key = ?", env.params.query["key"], as: String + file_extension = SQL.query_one "SELECT extension FROM files WHERE delete_key = ?", env.params.query["key"], as: String + File.delete("#{CONFIG.files}/#{file_to_delete}#{file_extension}") + SQL.exec "DELETE FROM files WHERE delete_key = ?", env.params.query["key"] + env.response.content_type = "application/json" + error_message = {"message" => "File deleted successfully"}.to_json + error_message + rescue + env.response.content_type = "application/json" + env.response.status_code = 403 + end + else + env.response.content_type = "application/json" + env.response.status_code = 403 + error_message = {"error" => "Huh? This delete key doesn't exist"}.to_json + error_message + end + end +end diff --git a/src/lib/base58.cr b/src/lib/base58.cr new file mode 100644 index 0000000..f40e6a5 --- /dev/null +++ b/src/lib/base58.cr @@ -0,0 +1,36 @@ +# https://github.com/crystal-china/base58.cr/blob/main/src/base58.cr +require "random" + +module Random + # Base58 string may contain alphanumeric characters except 0, O, I and l. + # ("0".."9").to_a + ("A".."Z").to_a + ("a".."z").to_a - ["0", "O", "I", "l"] + BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + def self.base58(length : Int32 = 16, random = Random::DEFAULT) : String + # Stolen from https://forum.crystal-lang.org/t/is-this-a-good-way-to-generate-a-random-string/6986/11, + # thank a lot for these awesome discussions in this thread. + + if length <= 1024 + buffer = uninitialized UInt8[1024] + bytes = buffer.to_slice[0...length] + else + bytes = Bytes.new(length) + end + + # then all valid indices are in [0,63], so just get a bunch of bytes + # and divide until they're guaranteed to be small enough + # (this seems to be about as fast as a right shift; the compiler probably optimizes it) + random.random_bytes(bytes) + bytes.map! { |v| v % BASE58_ALPHABET.bytesize } + + # and then use the buffer-based string constructor to set the characters + String.new(capacity: length) do |buffer| + bytes.each_with_index do |chars_index, buffer_index| + buffer[buffer_index] = BASE58_ALPHABET.byte_at(chars_index) + end + + # return size and bytesize (might differ if chars included non-ASCII) + {length, length} + end + end +end diff --git a/src/utils.cr b/src/utils.cr index 822a8af..c654d35 100644 --- a/src/utils.cr +++ b/src/utils.cr @@ -1,7 +1,18 @@ module Utils extend self - def create_files_directory + def create_db + puts "INFO: Creating sqlite3 database at '#{CONFIG.db}'" + begin + SQL.exec "CREATE TABLE IF NOT EXISTS files + (original_filename text, filename text, extension text, uploaded_at text, hash text, ip text, delete_key text)" + rescue ex + puts "ERROR: #{ex.message}" + exit + end + end + + def create_files_dir if !Dir.exists?("#{CONFIG.files}") begin Dir.mkdir("#{CONFIG.files}") @@ -15,6 +26,9 @@ module Utils def check_old_files puts "INFO: Deleting old files" dir = Dir.new("#{CONFIG.files}") + # Delete entries from DB + SQL.exec "DELETE FROM files WHERE uploaded_at < date('now', '-#{CONFIG.delete_files_after} days');" + # Delete files dir.each_child do |file| if (Time.utc - File.info("#{CONFIG.files}/#{file}").modification_time).days >= CONFIG.delete_files_after puts "INFO: Deleting file '#{file}'" @@ -29,4 +43,14 @@ module Utils # This is because the directory class is still saved on memory for some reason. dir.close end + + def hash_file(file_path : String) + File.open(file_path, "r") do |file| + # https://crystal-lang.org/api/master/IO/Digest.html + buffer = Bytes.new(256) + io = IO::Digest.new(file, Digest::SHA1.new) + io.read(buffer) + return io.final.hexstring + end + end end