0.7.0: Use standarized headers, automatic protocol detection and usage documentation

This commit is contained in:
Fijxu 2024-08-06 18:42:53 -04:00
parent 7938bf0335
commit 6f5e5c2007
Signed by: Fijxu
GPG key ID: 32C1DDF333EDA6A4
8 changed files with 164 additions and 84 deletions

View file

@ -1,20 +1,76 @@
# file-uploader # file-uploader
Simple file uploader made on Crystal. 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~~ Already replaced lol. ~~I'm making this to replace my current File uploader hosted on https://ayaya.beauty which uses https://github.com/nokonoko/uguu~~
Already replaced lol.
## Features ## Features
- Temporary file file uploader like Uguu - Temporary file uploads like Uguu
- File deletion link (not available in frontend for now) - File deletion link (not available in frontend for now)
- Chatterino and ShareX support - Chatterino and ShareX support
- Unix socket support if you don't want to deal with all the TCP overhead - Unix socket support if you don't want to deal with all the TCP overhead
- Low memory usage: Between 6MB at idle and 25MB if a file is being uploaded or retrieved. I will depend of your traffic. - Automatic protocol detection (HTTPS or HTTP)
- Low memory usage: Between 6MB at idle and 25MB if a file is being uploaded or retrieved. It will depend of your traffic.
## Usage
- Clone this repository, compile it using `shards build --release` and execute the server using `./bin/file-uploader`.
- Change the settings file `./config/config.yml` acording to what you need.
## NGINX Server block
Assuming you are already using NGINX and you know how to use it, you can use this example server block.
```
server {
# You can keep the domain prefixed with `~.` if you want
# to allow users to use any domain to upload and retrieve
# files. Like xdxd.example.com or lolol.example.com .
# This will only work if you have a wildcard domain.
server_name ~.example.com example.com;
location / {
proxy_pass http://127.0.0.1:8080;
# This if you want to use a UNIX socket instead
#proxy_pass http://unix:/tmp/file-uploader.sock;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_pass_request_headers on;
}
# This should be the size_limit value (from config.yml)
client_max_body_size 512M;
listen 443 ssl;
http2 on;
}
```
## Systemd user service example
```
[Unit]
Description=file-uploader-crystal
After=network.target
[Service]
Type=simple
Restart=always
RestartSec=2
LimitNOFILE=4096
Environment="KEMAL_ENV=production"
ExecStart=%h/file-uploader-crystal/bin/file-uploader
WorkingDirectory=%h/file-uploader-crystal/
[Install]
WantedBy=default.target
```
## TODO ## TODO
- ~~Add file size limit~~ ADDED - ~~Add file size limit~~ ADDED
- Fix error when accessing `http://127.0.0.1:8080` with an empty DB. - ~~Fix error when accessing `http://127.0.0.1:8080` with an empty DB.~~ Fixed somehow.
- Better frontend... - Better frontend...
- ~~Disable file deletion if `delete_files_after_check_seconds` or `delete_files_after` is set to `0`~~ DONE - ~~Disable file deletion if `delete_files_after_check_seconds` or `delete_files_after` is set to `0`~~ DONE
- ~~Disable delete key if `delete_key_length` is `0`~~ DONE (But I think there is a better way to do it) - ~~Disable delete key if `delete_key_length` is `0`~~ DONE (But I think there is a better way to do it)

View file

@ -1,6 +1,4 @@
files: "./files" files: "./files"
# Set to true if running behind a reverse proxy like nignx
secure: true
db: "./db.sqlite3" db: "./db.sqlite3"
db_table_name: "files" db_table_name: "files"
filename_length: 3 filename_length: 3
@ -8,7 +6,7 @@ filename_length: 3
size_limit: 512 size_limit: 512
port: 8080 port: 8080
# If you define the unix socket, it will only listen on the socket and not the port. # If you define the unix socket, it will only listen on the socket and not the port.
unix_socket: "/tmp/file-uploader.sock" #unix_socket: "/tmp/file-uploader.sock"
# In days # In days
delete_files_after: 7 delete_files_after: 7
# In seconds # In seconds

View file

@ -1,5 +1,5 @@
name: file-uploader name: file-uploader
version: 0.1.0 version: 0.7.0
authors: authors:
- Fijxu <fijxu@nadeko.net> - Fijxu <fijxu@nadeko.net>
@ -16,4 +16,4 @@ dependencies:
crystal: '>= 1.12.2' crystal: '>= 1.12.2'
license: MIT license: AGPL-3.0-only

View file

@ -4,7 +4,6 @@ class Config
include YAML::Serializable include YAML::Serializable
property files : String = "./files" property files : String = "./files"
property secure : Bool = false
property db : String = "./db.sqlite3" property db : String = "./db.sqlite3"
property db_table_name : String = "files" property db_table_name : String = "files"
property filename_length : Int8 = 3 property filename_length : Int8 = 3

View file

@ -33,6 +33,7 @@ Routing.register_all
Jobs.run Jobs.run
Utils.delete_socket
# Simple but ugly way # Simple but ugly way
if !CONFIG.unix_socket.nil? if !CONFIG.unix_socket.nil?
Kemal.run do |config| Kemal.run do |config|
@ -42,6 +43,11 @@ else
Kemal.run Kemal.run
end end
# Set permissions to 777 so NGINX can read and write to it (BROKEN)
sleep 1.second
LOGGER.info "Setting sock permissions to 777"
File.chmod("#{CONFIG.unix_socket}", File::Permissions::All)
{% if flag?(:release) || flag?(:production) %} {% if flag?(:release) || flag?(:production) %}
Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV") Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV")
{% end %} {% end %}

View file

@ -1,50 +1,13 @@
require "./http-errors"
module Handling module Handling
extend self extend self
private macro error401(message)
env.response.content_type = "application/json"
env.response.status_code = 401
error_message = {"error" => {{message}}}.to_json
return error_message
end
private macro error403(message)
env.response.content_type = "application/json"
env.response.status_code = 403
error_message = {"error" => {{message}}}.to_json
return error_message
end
private macro error404(message)
env.response.content_type = "application/json"
env.response.status_code = 404
error_message = {"error" => {{message}}}.to_json
return error_message
end
private macro error413(message)
env.response.content_type = "application/json"
env.response.status_code = 413
error_message = {"error" => {{message}}}.to_json
return error_message
end
private macro error500(message)
env.response.content_type = "application/json"
env.response.status_code = 500
error_message = {"error" => {{message}}}.to_json
return error_message
end
private macro msg(message)
env.response.content_type = "application/json"
msg = {"message" => {{message}}}.to_json
return msg
end
def upload(env) def upload(env)
env.response.content_type = "application/json" env.response.content_type = "application/json"
# You can modify this if you want to allow files smaller than 1MiB # You can modify this if you want to allow files smaller than 1MiB.
# This is generally a good way to check the filesize but there is a better way to do it
# which is inspecting the file directly (If I'm not wrong).
if CONFIG.size_limit > 0 if CONFIG.size_limit > 0
if env.request.headers["Content-Length"].to_i > 1048576*CONFIG.size_limit if env.request.headers["Content-Length"].to_i > 1048576*CONFIG.size_limit
error413("File is too big. The maximum size allowed is #{CONFIG.size_limit}MiB") error413("File is too big. The maximum size allowed is #{CONFIG.size_limit}MiB")
@ -57,9 +20,6 @@ end
file_hash = "" file_hash = ""
ip_address = "" ip_address = ""
delete_key = nil delete_key = nil
if CONFIG.delete_key_length > 0
delete_key = Random.base58(CONFIG.delete_key_length)
end
# TODO: Return the file that matches a checksum inside the database # TODO: Return the file that matches a checksum inside the database
HTTP::FormData.parse(env.request) do |upload| HTTP::FormData.parse(env.request) do |upload|
next if upload.filename.nil? || upload.filename.to_s.empty? next if upload.filename.nil? || upload.filename.to_s.empty?
@ -67,58 +27,65 @@ end
if CONFIG.blocked_extensions.includes?(extension.split(".")[1]) if CONFIG.blocked_extensions.includes?(extension.split(".")[1])
error401("Extension '#{extension}' is not allowed") error401("Extension '#{extension}' is not allowed")
end end
# TODO: Check if random string is already taken by some file (This will likely
# never happen but it is better to design it that way)
# filename = Random.base58(CONFIG.filename_length)
filename = Utils.generate_filename filename = Utils.generate_filename
if !filename.is_a?(String) file_path = ::File.join ["#{CONFIG.files}", filename + extension]
error403("This doesn't look like a file") File.open(file_path, "w") do |file|
else IO.copy(upload.body, file)
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.remote_address.to_s.split(":").first
SQL.exec "INSERT INTO #{CONFIG.db_table_name} VALUES (?, ?, ?, ?, ?, ?, ?)",
original_filename, filename, extension, uploaded_at, file_hash, ip_address, delete_key
end end
original_filename = upload.filename
uploaded_at = Time.utc
file_hash = Utils.hash_file(file_path)
# X-Forwarded-For if behind a reverse proxy and the header is set in the reverse
# proxy configuration.
ip_address = env.request.headers.try &.["X-Forwarded-For"]? ? env.request.headers.["X-Forwarded-For"] : env.request.remote_address.to_s.split(":").first
end end
if !filename.empty? if !filename.empty?
JSON.build do |j| protocol = env.request.headers.try &.["X-Forwarded-Proto"]? ? env.request.headers["X-Forwarded-Proto"] : "http"
host = env.request.headers.try &.["X-Forwarded-Host"]? ? env.request.headers["X-Forwarded-Host"] : env.request.headers["Host"]
json = JSON.build do |j|
j.object do j.object do
CONFIG.secure ? j.field "link", "https://#{env.request.headers["Host"]}/#{filename}" : j.field "link", "http://#{env.request.headers["Host"]}/#{filename}" j.field "link", "#{protocol}://#{host}/#{filename}"
j.field "linkExt", "https://#{env.request.headers["Host"]}/#{filename}#{extension}" j.field "linkExt", "#{protocol}://#{host}/#{filename}#{extension}"
j.field "id", filename j.field "id", filename
j.field "ext", extension j.field "ext", extension
j.field "name", original_filename j.field "name", original_filename
j.field "checksum", file_hash j.field "checksum", file_hash
if CONFIG.delete_key_length > 0 if CONFIG.delete_key_length > 0
delete_key = Random.base58(CONFIG.delete_key_length)
j.field "deleteKey", delete_key j.field "deleteKey", delete_key
j.field "deleteLink", "https://#{env.request.headers["Host"]}/delete?key=#{delete_key}" j.field "deleteLink", "#{protocol}://#{host}/delete?key=#{delete_key}"
end end
end end
end end
begin
# Insert SQL data just before returning the upload information
SQL.exec "INSERT INTO #{CONFIG.db_table_name} VALUES (?, ?, ?, ?, ?, ?, ?)",
original_filename, filename, extension, uploaded_at, file_hash, ip_address, delete_key
rescue ex
LOGGER.error "An error ocurred when trying to insert the data into the DB: #{ex.message}"
error500("An error ocurred when trying to insert the data into the DB")
end
return json
else else
error403("No file") LOGGER.debug "No file provided by the user"
error403("No file provided")
end end
end end
def retrieve_file(env) def retrieve_file(env)
begin begin
LOGGER.debug "#{env.request.headers["X-Real-IP"]} /#{env.params.url["filename"]}" LOGGER.debug "#{env.request.headers["X-Forwarded-For"]} /#{env.params.url["filename"]}"
rescue rescue
LOGGER.debug "NO X-Real-IP @ /#{env.params.url["filename"]}" LOGGER.debug "NO X-Forwarded-For @ /#{env.params.url["filename"]}"
end end
begin begin
filename = SQL.query_one "SELECT filename FROM #{CONFIG.db_table_name} WHERE filename = ?", env.params.url["filename"].to_s.split(".").first, as: String filename = SQL.query_one "SELECT filename FROM #{CONFIG.db_table_name} WHERE filename = ?", env.params.url["filename"].to_s.split(".").first, as: String
original_filename = SQL.query_one "SELECT original_filename FROM #{CONFIG.db_table_name} WHERE filename = ?", env.params.url["filename"].to_s.split(".").first, as: String
extension = SQL.query_one "SELECT extension FROM #{CONFIG.db_table_name} WHERE filename = ?", filename, as: String extension = SQL.query_one "SELECT extension FROM #{CONFIG.db_table_name} WHERE filename = ?", filename, as: String
headers(env, {"Content-Disposition" => "inline; filename*=UTF-8''#{original_filename}"})
send_file env, "#{CONFIG.files}/#{filename}#{extension}" send_file env, "#{CONFIG.files}/#{filename}#{extension}"
rescue rescue ex
LOGGER.debug "File #{filename} does not exists" LOGGER.debug "File #{filename} does not exist: #{ex.message}"
error403("File #{filename} does not exist") error403("File #{filename} does not exist")
end end
end end
@ -137,7 +104,7 @@ end
end end
end end
rescue ex rescue ex
LOGGER.error "#{ex.message}" LOGGER.error "Unknown error: #{ex.message}"
error500("Unknown error") error500("Unknown error")
end end
json_data json_data
@ -153,11 +120,12 @@ end
LOGGER.debug "File '#{file_to_delete}' was deleted using key '#{env.params.query["key"]}'}" LOGGER.debug "File '#{file_to_delete}' was deleted using key '#{env.params.query["key"]}'}"
msg("File '#{file_to_delete}' deleted successfully") msg("File '#{file_to_delete}' deleted successfully")
rescue ex rescue ex
error500("Unknown error: #{ex.message}") LOGGER.error("Unknown error: #{ex.message}")
error500("Unknown error")
end end
else else
LOGGER.debug "Key '#{env.params.query["key"]}' does not exist" LOGGER.debug "Key '#{env.params.query["key"]}' does not exist"
error401("Huh? This delete key doesn't exist") error401("Delete key '#{env.params.query["key"]}' does not exist. No files were deleted")
end end
end end
end end

40
src/http-errors.cr Normal file
View file

@ -0,0 +1,40 @@
macro error401(message)
env.response.content_type = "application/json"
env.response.status_code = 401
error_message = {"error" => {{message}}}.to_json
return error_message
end
macro error403(message)
env.response.content_type = "application/json"
env.response.status_code = 403
error_message = {"error" => {{message}}}.to_json
return error_message
end
macro error404(message)
env.response.content_type = "application/json"
env.response.status_code = 404
error_message = {"error" => {{message}}}.to_json
return error_message
end
macro error413(message)
env.response.content_type = "application/json"
env.response.status_code = 413
error_message = {"error" => {{message}}}.to_json
return error_message
end
macro error500(message)
env.response.content_type = "application/json"
env.response.status_code = 500
error_message = {"error" => {{message}}}.to_json
return error_message
end
macro msg(message)
env.response.content_type = "application/json"
msg = {"message" => {{message}}}.to_json
return msg
end

View file

@ -69,4 +69,17 @@ module Utils
end end
end end
end end
# Delete socket if the server has not been previously cleaned by the server (Due to unclean exits, crashes, etc.)
def delete_socket
if File.exists?("#{CONFIG.unix_socket}")
LOGGER.info "Deleting old unix socket"
begin
File.delete("#{CONFIG.unix_socket}")
rescue ex
LOGGER.fatal "#{ex.message}"
exit(1)
end
end
end
end end