Compare commits

...

9 commits
backup ... main

Author SHA1 Message Date
a4562ca005
feat(webserver): add host option to the configuration
All checks were successful
File-uploader-crystal CI / build (push) Successful in 1m41s
2025-01-02 19:06:05 -03:00
c554b772c8
0.9.3.5: only generate thumbnails on known extensions, remove trailing '/' from config.files and config.thumbnails
All checks were successful
File-uploader-crystal CI / build (push) Successful in 2m20s
2024-11-26 20:56:58 -03:00
bb9ecee67b
0.9.3.4: Fix what I did yesterday
All checks were successful
File-uploader-crystal CI / build (push) Successful in 1m49s
2024-11-21 13:30:19 -03:00
cb75b97520
0.9.3.3: Better handling when retrieving files, move rate limiter
All checks were successful
File-uploader-crystal CI / build (push) Successful in 1m47s
2024-11-21 04:02:12 -03:00
fdfa782e91
0.9.3.2-1: Update docker compose file
All checks were successful
File-uploader-crystal CI / build (push) Successful in 1m48s
2024-11-21 03:23:56 -03:00
99c22095f9
0.9.3.2: Delete entry from the DB is the file doesn't exists on the filesystem 2024-11-21 03:23:29 -03:00
9de4960932
0.9.3.1: Update Dockerfile and add compose file
All checks were successful
File-uploader-crystal CI / build (push) Successful in 1m49s
2024-11-19 23:16:32 -03:00
0002c81429
0.9.3: BUGFIX! Fix deletion of thumbnails on check_old_files job.
All checks were successful
File-uploader-crystal CI / build (push) Successful in 1m52s
- Add colors to logs
- Use static table names instead of config provided ones, it's kinda
  stupid to give the user an option to set the name of the table if I'm
  developing it for sqlite
2024-11-19 22:39:23 -03:00
b51513339c
0.9.2: Fix thumbnail folder generation, better chatterino config generation and better error hadling
All checks were successful
File-uploader-crystal CI / build (push) Successful in 2m15s
2024-10-21 13:54:51 -03:00
15 changed files with 270 additions and 199 deletions

2
.gitignore vendored
View file

@ -5,3 +5,5 @@
*.dwarf
data
torexitnodes.txt
files
thumbnails

View file

@ -1,5 +1,5 @@
# Based on https://github.com/iv-org/invidious/blob/master/docker/Dockerfile
FROM crystallang/crystal:1.13.2-alpine AS builder
FROM crystallang/crystal:1.14.0-alpine AS builder
RUN apk add --no-cache sqlite-static yaml-static
@ -19,8 +19,8 @@ RUN crystal build ./src/file-uploader-crystal.cr \
--release \
--static --warnings all
RUN apk add --no-cache tini
FROM alpine:3.18
FROM alpine:3.20
RUN apk add --no-cache tini ffmpeg
WORKDIR /file-uploader-crystal
RUN addgroup -g 1000 -S file-uploader-crystal && \
adduser -u 1000 -S file-uploader-crystal -G file-uploader-crystal

View file

@ -1,8 +1,8 @@
colorize_logs: true
files: "./files"
thumbnails: "./thumbnails"
generateThumbnails: true
db: "./db.sqlite3"
dbTableName: "files"
db: "./db/db.sqlite3"
adminEnabled: true
adminApiKey: "asd"
fileameLength: 3
@ -15,21 +15,21 @@ torExitNodesCheck: 1600
torExitNodesUrl: "https://check.torproject.org/exit-addresses"
torExitNodesFile: "./torexitnodes.txt"
torMessage: "TOR IS BLOCKED!"
# Set this to 0 to disable rate limiting
filesPerIP: 2
ipTableName: "ips"
rateLimitPeriod: 20
rateLimitMessage: ""
# If you define the unix socket, it will only listen on the socket and not the port.
#unix_socket: "/tmp/file-uploader.sock"
# In days
deleteFilesAfter: 1
deleteFilesAfter: 7
# In seconds
deleteFilesCheck: 1600
deleteKeyLength: 4
siteInfo: "Whatever you want to put here"
siteWarning: "WARNING!"
log_level: "debug"
blockedExtensions:
- "exe"
@ -38,7 +38,9 @@ opengraphUseragents:
- "chatterino-api-cache/"
- "FFZBot/"
- "Twitterbot/"
- "Synapse/"
- "Mastodon/"
alternativeDomains:
- "ayaya.beauty"
- "lamartina.gay"
# You can leave it empty, or add your own domains.
alternativeDomains:
- "example.com"

17
docker-compose.yml Normal file
View file

@ -0,0 +1,17 @@
services:
file-uploader:
image: git.nadeko.net/fijxu/file-uploader-crystal:latest
# This program should never use that many memory and more than 50% of the CPU
mem_limit: 512MB
cpus: 0.5
# If you want to use a custom config file, you can mount it here.
volumes:
# - ./config/config.yml:/file-uploader-crystal/config/config.yml
- ./public:/file-uploader-crystal/public
- ./files:/file-uploader-crystal/files
- ./thumbnails:/file-uploader-crystal/thumbnails
- ./db:/file-uploader-crystal/db
- ./torexitnodes.txt:/file-uploader-crystal/torexitnodes.txt
ports:
- 127.0.0.1:8080:8080

View file

@ -3,6 +3,8 @@ require "yaml"
class Config
include YAML::Serializable
# Colorize logs
property colorize_logs : Bool = true
# Where the uploaded files will be located
property files : String = "./files"
# Where the thumbnails will be located when they are successfully generated
@ -12,8 +14,6 @@ class Config
property generateThumbnails : Bool = false
# Where the SQLITE3 database will be located
property db : String = "./db.sqlite3"
# Name of the table that will be used for file information
property dbTableName : String = "files"
# Enable or disable the admin API
property adminEnabled : Bool = false
# The API key for admin routes. It's passed as a "X-Api-Key" header to the
@ -25,8 +25,10 @@ class Config
property fileameLength : Int32 = 3
# In MiB
property size_limit : Int16 = 512
# TCP port
# Port on which the uploader will bind
property port : Int32 = 8080
# IP address on which the uploader will bind
property host : String = "127.0.0.1"
# A file path where do you want to place a unix socket (THIS WILL DISABLE ACCESS
# BY IP ADDRESS)
property unix_socket : String?
@ -45,8 +47,6 @@ class Config
property torMessage : String? = "Tor is blocked!"
# How many files an IP address can upload to the server
property filesPerIP : Int32 = 32
# Name of the table that will be used for rate limit information
property ipTableName : String = "ips"
# How often is the file limit per IP reset? (in seconds)
property rateLimitPeriod : Int32 = 600
# TODO: UNUSED CONSTANT
@ -87,5 +87,12 @@ class Config
puts "Config: fileameLength cannot be #{config.fileameLength}"
exit(1)
end
if config.files.ends_with?('/')
config.files = config.files.chomp('/')
end
if config.thumbnails.ends_with?('/')
config.thumbnails = config.thumbnails.chomp('/')
end
end
end

View file

@ -15,10 +15,11 @@ require "./lib/**"
CONFIG = Config.load
Kemal.config.port = CONFIG.port
Kemal.config.host_binding = CONFIG.host
Kemal.config.shutdown_message = false
Kemal.config.app_name = "file-uploader-crystal"
# https://github.com/iv-org/invidious/blob/90e94d4e6cc126a8b7a091d12d7a5556bfe369d5/src/invidious.cr#L136C1-L136C61
LOGGER = LogHandler.new(STDOUT, CONFIG.log_level)
LOGGER = LogHandler.new(STDOUT, CONFIG.log_level, CONFIG.colorize_logs)
# Give me a 128 bit CPU
# MAX_FILES = 58**CONFIG.fileameLength
SQL = DB.open("sqlite3://#{CONFIG.db}")

View file

@ -17,7 +17,7 @@ module Handling::Admin
file = file.to_s
begin
fileinfo = SQL.query_one("SELECT filename, extension, thumbnail
FROM #{CONFIG.dbTableName}
FROM files
WHERE filename = ?",
file,
as: {filename: String, extension: String, thumbnail: String | Nil})
@ -29,7 +29,7 @@ module Handling::Admin
File.delete("#{CONFIG.thumbnails}/#{fileinfo[:thumbnail]}")
end
# Delete entry from db
SQL.exec "DELETE FROM #{CONFIG.dbTableName} WHERE filename = ?", file
SQL.exec "DELETE FROM files WHERE filename = ?", file
LOGGER.debug "File '#{fileinfo[:filename]}' was deleted"
successfull_files << file
rescue ex : DB::NoResultsError
@ -37,7 +37,7 @@ module Handling::Admin
failed_files << file
rescue ex
LOGGER.error "Unknown error: #{ex.message}"
error500 "Unknown error: #{ex.message}"
http_error 500,"Unknown error: #{ex.message}"
end
end
json = JSON.build do |j|
@ -61,7 +61,7 @@ module Handling::Admin
item = item.to_s
begin
# Delete entry from db
SQL.exec "DELETE FROM #{CONFIG.ipTableName} WHERE ip = ?", item
SQL.exec "DELETE FROM ips WHERE ip = ?", item
LOGGER.debug "Rate limit for '#{item}' was deleted"
successfull << item
rescue ex : DB::NoResultsError
@ -69,7 +69,7 @@ module Handling::Admin
failed << item
rescue ex
LOGGER.error "Unknown error: #{ex.message}"
error500 "Unknown error: #{ex.message}"
http_error 500, "Unknown error: #{ex.message}"
end
end
json = JSON.build do |j|
@ -95,7 +95,7 @@ module Handling::Admin
begin
fileinfo = SQL.query_one("SELECT original_filename, filename, extension,
uploaded_at, checksum, ip, delete_key, thumbnail
FROM #{CONFIG.dbTableName}
FROM files
WHERE filename = ?",
item,
as: {original_filename: String, filename: String, extension: String,
@ -107,7 +107,7 @@ module Handling::Admin
failed << item
rescue ex
LOGGER.error "Unknown error: #{ex.message}"
error500 "Unknown error: #{ex.message}"
http_error 500,"Unknown error: #{ex.message}"
end
end
json = JSON.build do |j|
@ -152,13 +152,13 @@ module Handling::Admin
# /api/admin/whitelist
# curl -X GET -H "X-Api-Key: asd" http://localhost:8080/api/admin/torexitnodes | jq
# def add_ip_to_whitelist(env, nodes)
# json = JSON.build do |j|
# j.object do
# j.field "ips", nodes
# end
# end
# end
# def add_ip_to_whitelist(env, nodes)
# json = JSON.build do |j|
# j.object do
# j.field "ips", nodes
# end
# end
# end
# /api/admin/blacklist
# curl -X GET -H "X-Api-Key: asd" http://localhost:8080/api/admin/torexitnodes | jq

View file

@ -2,6 +2,8 @@ require "../http-errors"
require "http/client"
require "benchmark"
# require "../filters"
module Handling
extend self
@ -10,12 +12,13 @@ module Handling
ip_address = Utils.ip_address(env)
protocol = Utils.protocol(env)
host = Utils.host(env)
# filter = env.params.query["filter"]?
# 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 env.request.headers["Content-Length"].to_i > 1048576*CONFIG.size_limit
return error413("File is too big. The maximum size allowed is #{CONFIG.size_limit}MiB")
return http_error 413, "File is too big. The maximum size allowed is #{CONFIG.size_limit}MiB"
end
end
filename = ""
@ -30,22 +33,26 @@ module Handling
HTTP::FormData.parse(env.request) do |upload|
if upload.filename.nil? || upload.filename.to_s.empty?
LOGGER.debug "No file provided by the user"
return error403("No file provided")
return http_error 403, "No file provided"
end
# TODO: upload.body is emptied when is copied or read
# Utils.check_duplicate(upload.dup)
extension = File.extname("#{upload.filename}")
if CONFIG.blockedExtensions.includes?(extension.split(".")[1])
return error401("Extension '#{extension}' is not allowed")
return http_error 401, "Extension '#{extension}' is not allowed"
end
filename = Utils.generate_filename
file_path = ::File.join ["#{CONFIG.files}", filename + extension]
file_path = "#{CONFIG.files}/#{filename}#{extension}"
File.open(file_path, "w") do |output|
IO.copy(upload.body, output)
end
original_filename = upload.filename
uploaded_at = Time.utc
checksum = Utils.hash_file(file_path)
# TODO: Apply filters
# if filter
# Filters.apply_filter(file_path, filter)
# end
end
# X-Forwarded-For if behind a reverse proxy and the header is set in the reverse
# proxy configuration.
@ -56,14 +63,14 @@ module Handling
end
begin
# Insert SQL data just before returning the upload information
SQL.exec "INSERT INTO #{CONFIG.dbTableName} VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
SQL.exec "INSERT INTO files VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
original_filename, filename, extension, uploaded_at, checksum, ip_address, delete_key, nil
SQL.exec "INSERT OR IGNORE INTO #{CONFIG.ipTableName} (ip, date) VALUES (?, ?)", ip_address, Time.utc.to_unix
# SQL.exec "INSERT OR IGNORE INTO #{CONFIG.ipTableName} (ip) VALUES ('#{ip_address}')"
SQL.exec "UPDATE #{CONFIG.ipTableName} SET count = count + 1 WHERE ip = ('#{ip_address}')"
SQL.exec "INSERT OR IGNORE INTO ips (ip, date) VALUES (?, ?)", ip_address, Time.utc.to_unix
# SQL.exec "INSERT OR IGNORE INTO ips (ip) VALUES ('#{ip_address}')"
SQL.exec "UPDATE ips SET count = count + 1 WHERE ip = ('#{ip_address}')"
rescue ex
LOGGER.error "An error ocurred when trying to insert the data into the DB: #{ex.message}"
return error500("An error ocurred when trying to insert the data into the DB")
return http_error 500, "An error ocurred when trying to insert the data into the DB"
end
json = JSON.build do |j|
j.object do
@ -88,13 +95,19 @@ module Handling
ip_address = Utils.ip_address(env)
protocol = Utils.protocol(env)
host = Utils.host(env)
files = env.params.json["files"].as((Array(JSON::Any)))
begin
files = env.params.json["files"].as((Array(JSON::Any)))
rescue ex : JSON::ParseException
LOGGER.error "Body malformed: #{ex.message}"
return http_error 400, "Body malformed: #{ex.message}"
rescue ex
LOGGER.error "Unknown error: #{ex.message}"
return http_error 500, "Unknown error"
end
successfull_files = [] of NamedTuple(filename: String, extension: String, original_filename: String, checksum: String, delete_key: String | Nil)
failed_files = [] of String
# X-Forwarded-For if behind a reverse proxy and the header is set in the reverse
# proxy configuration.
if files.empty?
end
files.each do |url|
url = url.to_s
filename = Utils.generate_filename
@ -103,8 +116,10 @@ module Handling
checksum = ""
uploaded_at = Time.utc
extension = File.extname(URI.parse(url).path)
delete_key = nil
file_path = ::File.join ["#{CONFIG.files}", filename + extension]
if CONFIG.deleteKeyLength > 0
delete_key = Random.base58(CONFIG.deleteKeyLength)
end
file_path = "#{CONFIG.files}/#{filename}#{extension}"
File.open(file_path, "w") do |output|
begin
HTTP::Client.get(url) do |res|
@ -112,7 +127,7 @@ module Handling
end
rescue ex
LOGGER.debug "Failed to download file '#{url}': #{ex.message}"
return error403("Failed to download file '#{url}'")
return http_error 403, "Failed to download file '#{url}'"
failed_files << url
end
end
@ -121,7 +136,7 @@ module Handling
if extension.empty?
extension = Utils.detect_extension(file_path)
File.rename(file_path, file_path + extension)
file_path = ::File.join ["#{CONFIG.files}", filename + extension]
file_path = "#{CONFIG.files}/#{filename}#{extension}"
end
# The second one is faster and it uses less memory
# original_filename = URI.parse("https://ayaya.beauty/PqC").path.split("/").last
@ -134,7 +149,7 @@ module Handling
end
begin
# Insert SQL data just before returning the upload information
SQL.exec("INSERT INTO #{CONFIG.dbTableName} VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
SQL.exec("INSERT INTO files VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
original_filename, filename, extension, uploaded_at, checksum, ip_address, delete_key, nil)
successfull_files << {filename: filename,
original_filename: original_filename,
@ -143,7 +158,7 @@ module Handling
checksum: checksum}
rescue ex
LOGGER.error "An error ocurred when trying to insert the data into the DB: #{ex.message}"
return error500("An error ocurred when trying to insert the data into the DB")
return http_error 500, "An error ocurred when trying to insert the data into the DB"
end
end
json = JSON.build do |j|
@ -168,7 +183,6 @@ module Handling
json
end
# TODO: Add delete url, same for upload_url_bulk
def upload_url(env)
env.response.content_type = "application/json"
ip_address = Utils.ip_address(env)
@ -179,35 +193,33 @@ module Handling
failed_files = [] of String
# X-Forwarded-For if behind a reverse proxy and the header is set in the reverse
# proxy configuration.
if url.empty?
end
# files.each do |url|
url = url.to_s
filename = Utils.generate_filename
original_filename = ""
extension = ""
checksum = ""
uploaded_at = Time.utc
extension = File.extname(URI.parse(url).path)
delete_key = nil
file_path = ::File.join ["#{CONFIG.files}", filename + extension]
if CONFIG.deleteKeyLength > 0
delete_key = Random.base58(CONFIG.deleteKeyLength)
end
file_path = "#{CONFIG.files}/#{filename}#{extension}"
File.open(file_path, "w") do |output|
begin
# TODO: Connect timeout to prevent possible Denial of Service to the external website spamming requests
# https://crystal-lang.org/api/1.13.2/HTTP/Client.html#connect_timeout
HTTP::Client.get(url) do |res|
IO.copy(res.body_io, output)
end
rescue ex
LOGGER.debug "Failed to download file '#{url}': #{ex.message}"
return error403("Failed to download file '#{url}'")
return http_error 403, "Failed to download file '#{url}': #{ex.message}"
failed_files << url
end
end
# successfull_files << url
# end
if extension.empty?
extension = Utils.detect_extension(file_path)
File.rename(file_path, file_path + extension)
file_path = ::File.join ["#{CONFIG.files}", filename + extension]
file_path = "#{CONFIG.files}/#{filename}#{extension}"
end
# The second one is faster and it uses less memory
# original_filename = URI.parse("https://ayaya.beauty/PqC").path.split("/").last
@ -220,7 +232,7 @@ module Handling
end
begin
# Insert SQL data just before returning the upload information
SQL.exec("INSERT INTO #{CONFIG.dbTableName} VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
SQL.exec("INSERT INTO files VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
original_filename, filename, extension, uploaded_at, checksum, ip_address, delete_key, nil)
successfull_files << {filename: filename,
original_filename: original_filename,
@ -229,9 +241,8 @@ module Handling
checksum: checksum}
rescue ex
LOGGER.error "An error ocurred when trying to insert the data into the DB: #{ex.message}"
return error500("An error ocurred when trying to insert the data into the DB")
return http_error 500, "An error ocurred when trying to insert the data into the DB"
end
# end
json = JSON.build do |j|
j.array do
successfull_files.each do |fileinfo|
@ -255,34 +266,30 @@ module Handling
end
def retrieve_file(env)
protocol = Utils.protocol(env)
host = Utils.host(env)
begin
protocol = Utils.protocol(env)
host = Utils.host(env)
fileinfo = SQL.query_all("SELECT filename, original_filename, uploaded_at, extension, checksum, thumbnail
FROM #{CONFIG.dbTableName}
fileinfo = SQL.query_one?("SELECT filename, original_filename, uploaded_at, extension, checksum, thumbnail
FROM files
WHERE filename = ?",
env.params.url["filename"].split(".").first,
as: {filename: String, ofilename: String, up_at: String, ext: String, checksum: String, thumbnail: String | Nil})[0]
# Benchmark.ips do |x|
# x.report("header multiple") { headers(env, {"Content-Disposition" => "inline; filename*=UTF-8''#{fileinfo[:ofilename]}",
# "Last-Modified" => "#{fileinfo[:up_at]}",
# "ETag" => "#{fileinfo[:checksum]}"}) }
# x.report("shorter sleep") do
# env.response.headers["Content-Disposition"] = "inline; filename*=UTF-8''#{fileinfo[:ofilename]}"
# env.response.headers["Last-Modified"] = "#{fileinfo[:up_at]}"
# env.response.headers["ETag"] = "#{fileinfo[:checksum]}"
# end
# end
# `env.response.headers` is faster than `headers(env, Hash(String, String))`
# https://github.com/kemalcr/kemal/blob/3243b8e0e03568ad3bd9f0ad6f445c871605b821/src/kemal/helpers/helpers.cr#L102C1-L104C4
env.response.headers["Content-Disposition"] = "inline; filename*=UTF-8''#{fileinfo[:ofilename]}"
# env.response.headers["Last-Modified"] = "#{fileinfo[:up_at]}"
env.response.headers["ETag"] = "#{fileinfo[:checksum]}"
as: {filename: String, ofilename: String, up_at: String, ext: String, checksum: String, thumbnail: String | Nil})
if fileinfo.nil?
# TODO: Switch this to 404, if I use 404, it will use the kemal error page (ANOYING!)
return http_error 418, "File '#{env.params.url["filename"]}' does not exist"
end
rescue ex
LOGGER.debug "Error when retrieving file '#{env.params.url["filename"]}': #{ex.message}"
return http_error 500, "Error when retrieving file '#{env.params.url["filename"]}'"
end
env.response.headers["Content-Disposition"] = "inline; filename*=UTF-8''#{fileinfo[:ofilename]}"
# env.response.headers["Last-Modified"] = "#{fileinfo[:up_at]}"
env.response.headers["ETag"] = "#{fileinfo[:checksum]}"
CONFIG.opengraphUseragents.each do |useragent|
if env.request.headers.try &.["User-Agent"].includes?(useragent)
env.response.content_type = "text/html"
return %(
CONFIG.opengraphUseragents.each do |useragent|
if env.request.headers.try &.["User-Agent"].includes?(useragent)
env.response.content_type = "text/html"
return %(
<!DOCTYPE html>
<html lang="en">
<head>
@ -295,13 +302,9 @@ module Handling
</head>
</html>
)
end
end
send_file env, "#{CONFIG.files}/#{fileinfo[:filename]}#{fileinfo[:ext]}"
rescue ex
LOGGER.debug "File '#{env.params.url["filename"]}' does not exist: #{ex.message}"
return error403("File '#{env.params.url["filename"]}' does not exist")
end
send_file env, "#{CONFIG.files}/#{fileinfo[:filename]}#{fileinfo[:ext]}"
end
def retrieve_thumbnail(env)
@ -309,7 +312,7 @@ module Handling
send_file env, "#{CONFIG.thumbnails}/#{env.params.url["thumbnail"]}"
rescue ex
LOGGER.debug "Thumbnail '#{env.params.url["thumbnail"]}' does not exist: #{ex.message}"
return error403("Thumbnail '#{env.params.url["thumbnail"]}' does not exist")
return http_error 403, "Thumbnail '#{env.params.url["thumbnail"]}' does not exist"
end
end
@ -320,7 +323,7 @@ module Handling
json.object do
json.field "stats" do
json.object do
json.field "filesHosted", SQL.query_one "SELECT COUNT (filename) FROM #{CONFIG.dbTableName}", as: Int32
json.field "filesHosted", SQL.query_one? "SELECT COUNT (filename) FROM files", as: Int32
json.field "maxUploadSize", CONFIG.size_limit
json.field "thumbnailGeneration", CONFIG.generateThumbnails
json.field "filenameLength", CONFIG.fileameLength
@ -331,16 +334,16 @@ module Handling
end
rescue ex
LOGGER.error "Unknown error: #{ex.message}"
return error500("Unknown error")
return http_error 500, "Unknown error"
end
json_data
end
def delete_file(env)
if SQL.query_one "SELECT EXISTS(SELECT 1 FROM #{CONFIG.dbTableName} WHERE delete_key = ?)", env.params.query["key"], as: Bool
if SQL.query_one "SELECT EXISTS(SELECT 1 FROM files WHERE delete_key = ?)", env.params.query["key"], as: Bool
begin
fileinfo = SQL.query_all("SELECT filename, extension, thumbnail
FROM #{CONFIG.dbTableName}
FROM files
WHERE delete_key = ?",
env.params.query["key"],
as: {filename: String, extension: String, thumbnail: String | Nil})[0]
@ -352,16 +355,16 @@ module Handling
File.delete("#{CONFIG.thumbnails}/#{fileinfo[:thumbnail]}")
end
# Delete entry from db
SQL.exec "DELETE FROM #{CONFIG.dbTableName} WHERE delete_key = ?", env.params.query["key"]
SQL.exec "DELETE FROM files WHERE delete_key = ?", env.params.query["key"]
LOGGER.debug "File '#{fileinfo[:filename]}' was deleted using key '#{env.params.query["key"]}'}"
return msg("File '#{fileinfo[:filename]}' deleted successfully")
rescue ex
LOGGER.error("Unknown error: #{ex.message}")
return error500("Unknown error")
return http_error 500, "Unknown error"
end
else
LOGGER.debug "Key '#{env.params.query["key"]}' does not exist"
return error401("Delete key '#{env.params.query["key"]}' does not exist. No files were deleted")
return http_error 401, "Delete key '#{env.params.query["key"]}' does not exist. No files were deleted"
end
end
@ -383,4 +386,16 @@ module Handling
"ErrorMessage": "{json:error}"
})
end
def chatterino_config(env)
host = Utils.host(env)
protocol = Utils.protocol(env)
env.response.content_type = "application/json"
return %({
"requestUrl": "#{protocol}://#{host}/upload",
"formField": "data",
"imageLink": "{link}",
"deleteLink": "{deleteLink}"
})
end
end

View file

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

View file

@ -8,7 +8,7 @@ module Jobs
spawn do
loop do
Utils.check_old_files
sleep CONFIG.deleteFilesCheck
sleep CONFIG.deleteFilesCheck.seconds
end
end
end
@ -17,12 +17,13 @@ module Jobs
if !CONFIG.blockTorAddresses
return
end
LOGGER.info("Blocking Tor exit nodes")
spawn do
loop do
Utils.retrieve_tor_exit_nodes
# Updates the @@exit_nodes array instantly
Routing.reload_exit_nodes
sleep CONFIG.torExitNodesCheck
sleep CONFIG.torExitNodesCheck.seconds
end
end
end

View file

@ -1,4 +1,6 @@
# https://github.com/iv-org/invidious/blob/master/src/invidious/helpers/logger.cr
require "colorize"
enum LogLevel
All = 0
Trace = 1
@ -11,7 +13,9 @@ enum LogLevel
end
class LogHandler < Kemal::BaseLogHandler
def initialize(@io : IO = STDOUT, @level = LogLevel::Debug)
def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true)
Colorize.enabled = use_color
Colorize.on_tty_only!
end
def call(context : HTTP::Server::Context)
@ -35,28 +39,27 @@ class LogHandler < Kemal::BaseLogHandler
context
end
def puts(message : String)
@io << message << '\n'
@io.flush
end
def write(message : String)
@io << message
@io.flush
end
def set_log_level(level : String)
@level = LogLevel.parse(level)
end
def set_log_level(level : LogLevel)
@level = level
def color(level)
case level
when LogLevel::Trace then :cyan
when LogLevel::Debug then :green
when LogLevel::Info then :white
when LogLevel::Warn then :yellow
when LogLevel::Error then :red
when LogLevel::Fatal then :magenta
else :default
end
end
{% for level in %w(trace debug info warn error fatal) %}
def {{level.id}}(message : String)
if LogLevel::{{level.id.capitalize}} >= @level
puts("#{Time.utc} [{{level.id}}] #{message}")
puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}})))
end
end
{% end %}

View file

@ -7,12 +7,36 @@ module Routing
def reload_exit_nodes
LOGGER.debug "Updating Tor exit nodes array"
@@exit_nodes = Utils.load_tor_exit_nodes
LOGGER.debug "IPs inside the exit nodes array: #{@@exit_nodes.size}"
LOGGER.debug "IPs inside the Tor exit nodes array: #{@@exit_nodes.size}"
end
before_post "/api/admin/*" do |env|
if env.request.headers.try &.["X-Api-Key"]? != CONFIG.adminApiKey || nil
halt env, status_code: 401, response: error401("Wrong API Key")
halt env, status_code: 401, response: http_error 401, "Wrong API Key"
end
end
before_post "/upload" do |env|
begin
ip_info = SQL.query_one?("SELECT ip, count, date FROM ips WHERE ip = ?", Utils.ip_address(env), as: {ip: String, count: Int32, date: Int32})
rescue ex
LOGGER.error "Error when trying to enforce rate limits: #{ex.message}"
next
end
if ip_info.nil?
next
end
time_since_first_upload = Time.utc.to_unix - ip_info[:date]
time_until_unban = ip_info[:date] - Time.utc.to_unix + CONFIG.rateLimitPeriod
if time_since_first_upload > CONFIG.rateLimitPeriod
SQL.exec "DELETE FROM ips WHERE ip = ?", ip_info[:ip]
end
if CONFIG.filesPerIP > 0
if ip_info[:count] >= CONFIG.filesPerIP && time_since_first_upload < CONFIG.rateLimitPeriod
halt env, status_code: 401, response: http_error 401, "Rate limited! Try again in #{time_until_unban} seconds"
end
end
end
@ -22,34 +46,23 @@ module Routing
next
end
if CONFIG.blockTorAddresses && @@exit_nodes.includes?(Utils.ip_address(env))
halt env, status_code: 401, response: error401(CONFIG.torMessage)
end
# There is a better way to do this
if env.request.resource == "/upload"
begin
ip_info = SQL.query_all("SELECT ip, count, date FROM #{CONFIG.ipTableName} WHERE ip = ?", Utils.ip_address(env), as: {ip: String, count: Int32, date: Int32})[0]
time_since_first_upload = Time.utc.to_unix - ip_info[:date]
time_until_unban = ip_info[:date] - Time.utc.to_unix + CONFIG.rateLimitPeriod
if time_since_first_upload > CONFIG.rateLimitPeriod
SQL.exec "DELETE FROM #{CONFIG.ipTableName} WHERE ip = ?", ip_info[:ip]
end
if ip_info[:count] >= CONFIG.filesPerIP && time_since_first_upload < CONFIG.rateLimitPeriod
halt env, status_code: 401, response: error401("Rate limited! Try again in #{time_until_unban} seconds")
end
rescue ex
LOGGER.error "Error when trying to enforce rate limits: #{ex.message}"
next
end
halt env, status_code: 401, response: http_error 401, CONFIG.torMessage
end
end
def register_all
get "/" do |env|
host = Utils.host(env)
files_hosted = SQL.query_one "SELECT COUNT (filename) FROM #{CONFIG.dbTableName}", as: Int32
files_hosted = SQL.query_one "SELECT COUNT (filename) FROM files", as: Int32
render "src/views/index.ecr"
end
get "/chatterino" do |env|
host = Utils.host(env)
protocol = Utils.protocol(env)
render "src/views/chatterino.ecr"
end
post "/upload" do |env|
Handling.upload(env)
end
@ -82,6 +95,10 @@ module Routing
Handling.sharex_config(env)
end
get "/chatterinoconfig" do |env|
Handling.chatterino_config(env)
end
if CONFIG.adminEnabled
self.register_admin
end
@ -107,4 +124,8 @@ module Routing
get "/api/admin/torexitnodes" do |env|
Handling::Admin.retrieve_tor_exit_nodes(env, @@exit_nodes)
end
error 404 do
"File not found"
end
end

View file

@ -2,13 +2,13 @@ module Utils
extend self
def create_db
if !SQL.query_one "SELECT EXISTS (SELECT 1 FROM sqlite_schema WHERE type='table' AND name='#{CONFIG.dbTableName}')
AND EXISTS (SELECT 1 FROM sqlite_schema WHERE type='table' AND name='#{CONFIG.ipTableName}');", as: Bool
if !SQL.query_one "SELECT EXISTS (SELECT 1 FROM sqlite_schema WHERE type='table' AND name='files')
AND EXISTS (SELECT 1 FROM sqlite_schema WHERE type='table' AND name='ips');", as: Bool
LOGGER.info "Creating sqlite3 database at '#{CONFIG.db}'"
begin
SQL.exec "CREATE TABLE IF NOT EXISTS #{CONFIG.dbTableName}
SQL.exec "CREATE TABLE IF NOT EXISTS files
(original_filename text, filename text, extension text, uploaded_at text, checksum text, ip text, delete_key text, thumbnail text)"
SQL.exec "CREATE TABLE IF NOT EXISTS #{CONFIG.ipTableName}
SQL.exec "CREATE TABLE IF NOT EXISTS ips
(ip text UNIQUE, count integer DEFAULT 0, date integer)"
rescue ex
LOGGER.fatal "#{ex.message}"
@ -30,7 +30,7 @@ module Utils
end
def create_thumbnails_dir
if !CONFIG.thumbnails
if CONFIG.thumbnails
if !Dir.exists?("#{CONFIG.thumbnails}")
LOGGER.info "Creating thumbnails folder under '#{CONFIG.thumbnails}'"
begin
@ -45,23 +45,25 @@ module Utils
def check_old_files
LOGGER.info "Deleting old files"
dir = Dir.new("#{CONFIG.files}")
# Delete entries from DB
SQL.exec "DELETE FROM #{CONFIG.dbTableName} WHERE uploaded_at < date('now', '-#{CONFIG.deleteFilesAfter} days');"
# Delete files
dir.each_child do |file|
if (Time.utc - File.info("#{CONFIG.files}/#{file}").modification_time).days >= CONFIG.deleteFilesAfter
LOGGER.debug "Deleting file '#{file}'"
begin
File.delete("#{CONFIG.files}/#{file}")
rescue ex
LOGGER.error "#{ex.message}"
fileinfo = SQL.query_all("SELECT filename, extension, thumbnail
FROM files
WHERE uploaded_at < datetime('now', '-#{CONFIG.deleteFilesAfter} days')",
as: {filename: String, extension: String, thumbnail: String | Nil})
fileinfo.each do |file|
LOGGER.debug "Deleting file '#{file[:filename]}#{file[:extension]}'"
begin
File.delete("#{CONFIG.files}/#{file[:filename]}#{file[:extension]}")
if file[:thumbnail]
File.delete("#{CONFIG.thumbnails}/#{file[:thumbnail]}")
end
SQL.exec "DELETE FROM files WHERE filename = ?", file[:filename]
rescue ex
LOGGER.error "#{ex.message}"
# Also delete the file entry from the DB if it doesn't exist.
SQL.exec "DELETE FROM files WHERE filename = ?", file[:filename]
end
end
# Close directory to prevent `Too many open files (File::Error)` error.
# This is because the directory class is still saved on memory for some reason.
dir.close
end
def check_dependencies
@ -69,7 +71,7 @@ module Utils
dependencies.each do |dep|
next if !CONFIG.generateThumbnails
if !Process.find_executable(dep)
LOGGER.fatal("'#{dep}' was not found")
LOGGER.fatal("'#{dep}' was not found.")
exit(1)
end
end
@ -77,7 +79,7 @@ module Utils
# TODO:
# def check_duplicate(upload)
# file_checksum = SQL.query_all("SELECT checksum FROM #{CONFIG.dbTableName} WHERE original_filename = ?", upload.filename, as:String).try &.[0]?
# file_checksum = SQL.query_all("SELECT checksum FROM files WHERE original_filename = ?", upload.filename, as:String).try &.[0]?
# if file_checksum.nil?
# return
# else
@ -101,8 +103,9 @@ module Utils
# TODO: Check if there are no other possibilities to get a random filename and exit
def generate_filename
filename = Random.base58(CONFIG.fileameLength)
loop do
if SQL.query_one("SELECT COUNT(filename) FROM #{CONFIG.dbTableName} WHERE filename = ?", filename, as: Int32) == 0
if SQL.query_one("SELECT COUNT(filename) FROM files WHERE filename = ?", filename, as: Int32) == 0
return filename
else
LOGGER.debug "Filename collision! Generating a new filename"
@ -112,8 +115,14 @@ module Utils
end
def generate_thumbnail(filename, extension)
exts = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".heic", ".jxl", ".avif", ".crw", ".dng",
".mp4", ".mkv", ".webm", ".avi", ".wmv", ".flv", "m4v", ".mov", ".amv", ".3gp", ".mpg", ".mpeg", ".yuv"]
# To prevent thumbnail generation on non image extensions
return if exts.none? do |ext|
extension.downcase.includes?(ext)
end
# Disable generation if false
return if !CONFIG.generateThumbnails
return if !CONFIG.generateThumbnails || !CONFIG.thumbnails
LOGGER.debug "Generating thumbnail for #{filename + extension} in background"
process = Process.run("ffmpeg",
[
@ -128,10 +137,11 @@ module Utils
"-update", "1",
"#{CONFIG.thumbnails}/#{filename}.jpg",
])
if process.normal_exit?
if process.exit_code == 0
LOGGER.debug "Thumbnail for #{filename + extension} generated successfully"
SQL.exec "UPDATE #{CONFIG.dbTableName} SET thumbnail = ? WHERE filename = ?", filename + ".jpg", filename
SQL.exec "UPDATE files SET thumbnail = ? WHERE filename = ?", filename + ".jpg", filename
else
# TODO: Add some sort of message when the thumbnail is not generated
end
end
@ -159,11 +169,11 @@ module Utils
# Delete file
File.delete("#{CONFIG.files}/#{fileinfo[:filename]}#{fileinfo[:extension]}")
if fileinfo[:thumbnail]
# Delete thumbnail
File.delete("#{CONFIG.thumbnails}/#{fileinfo[:thumbnail]}")
end
# Delete entry from db
SQL.exec "DELETE FROM #{CONFIG.dbTableName} WHERE delete_key = ?", env.params.query["key"]
SQL.exec "DELETE FROM files WHERE delete_key = ?", env.params.query["key"]
LOGGER.debug "File '#{fileinfo[:filename]}' was deleted using key '#{env.params.query["key"]}'}"
msg("File '#{fileinfo[:filename]}' deleted successfully")
end
@ -220,7 +230,7 @@ module Utils
begin
File.open(CONFIG.torExitNodesFile, "w") { |output| IO.copy(res.body_io, output) }
rescue ex
LOGGER.error "Failed to write to file: #{ex.message}"
LOGGER.error "Failed to save exit nodes list: #{ex.message}"
end
else
LOGGER.error "Failed to retrieve exit nodes list. Status Code: #{res.status_code}"

20
src/views/chatterino.ecr Normal file
View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> <%= host %> </title>
<link rel="stylesheet" href="styles.css">
<link rel="icon" href="./favicon.gif" type="image/gif" />
<script src="script.js"></script>
</head>
<body>
<div class="container">
<h1 style="font-size: 68px; text-align: center; margin: 20px;">Chatterino config</h1>
<p>Request URL: <a style="color: #2cca00"><%= protocol %>://<%= host %>/upload</a></p>
<p>Form field: <a style="color: #2cca00">data</a></p>
<p>Image link: <a style="color: #2cca00">link</a></p>
<p>Delete link: <a style="color: #2cca00">deleteLink</a></p>
</div>
</body>
</html>

View file

@ -22,7 +22,7 @@
<div>
<div style="text-align:center;">
<p>
<a href='./chatterino.png'>Chatterino Config</a> |
<a href='./chatterino'>Chatterino Config</a> |
<a href='./sharex.sxcu'>ShareX Config</a> |
<a href='https://codeberg.org/Fijxu/file-uploader-crystal'>
file-uploader-crystal (BETA <%= CURRENT_TAG %> - <%= CURRENT_VERSION %> @ <%= CURRENT_BRANCH %>)