0.3.0: Added DB + Deletion support
This commit is contained in:
parent
257efce944
commit
17f1efa214
8 changed files with 213 additions and 36 deletions
|
@ -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
|
9
spec/file-uploader_spec.cr
Normal file
9
spec/file-uploader_spec.cr
Normal file
|
@ -0,0 +1,9 @@
|
|||
require "./spec_helper"
|
||||
|
||||
describe File::Uploader do
|
||||
# TODO: Write tests
|
||||
|
||||
it "works" do
|
||||
false.should eq(true)
|
||||
end
|
||||
end
|
2
spec/spec_helper.cr
Normal file
2
spec/spec_helper.cr
Normal file
|
@ -0,0 +1,2 @@
|
|||
require "spec"
|
||||
require "../src/file-uploader"
|
|
@ -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
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
122
src/handling.cr
Normal file
122
src/handling.cr
Normal file
|
@ -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
|
36
src/lib/base58.cr
Normal file
36
src/lib/base58.cr
Normal file
|
@ -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
|
26
src/utils.cr
26
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
|
||||
|
|
Loading…
Reference in a new issue