Compare commits
24 commits
Author | SHA1 | Date | |
---|---|---|---|
7cc2ad9117 | |||
44d7afddfc | |||
c0f73bab0a | |||
b65acac27d | |||
b19c423648 | |||
8995f023ac | |||
a4562ca005 | |||
c554b772c8 | |||
bb9ecee67b | |||
cb75b97520 | |||
fdfa782e91 | |||
99c22095f9 | |||
9de4960932 | |||
0002c81429 | |||
b51513339c | |||
4803700cab | |||
493322039d | |||
c7f30c8245 | |||
db51190e5f | |||
e1a64b225d | |||
cbeba2b0a2 | |||
7ee5956970 | |||
69fe5a3c58 | |||
cea1982523 |
40 changed files with 1678 additions and 1059 deletions
46
.forgejo/workflows/ci.yml
Normal file
46
.forgejo/workflows/ci.yml
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
name: 'File-uploader-crystal CI'
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 7 * * 0'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: runner
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: https://code.forgejo.org/actions/checkout@v2
|
||||||
|
|
||||||
|
- uses: https://code.forgejo.org/docker/setup-buildx-action@v3
|
||||||
|
name: Setup Docker BuildX system
|
||||||
|
|
||||||
|
- name: Login to Docker Container Registry
|
||||||
|
uses: https://code.forgejo.org/docker/login-action@v3.1.0
|
||||||
|
with:
|
||||||
|
registry: git.nadeko.net
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
password: ${{ secrets.TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: https://github.com/docker/metadata-action@v6
|
||||||
|
with:
|
||||||
|
images: git.nadeko.net/fijxu/file-uploader-crystal
|
||||||
|
tags: |
|
||||||
|
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
|
||||||
|
|
||||||
|
- uses: https://code.forgejo.org/docker/build-push-action@v6
|
||||||
|
name: Build images
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -5,3 +5,7 @@
|
||||||
*.dwarf
|
*.dwarf
|
||||||
data
|
data
|
||||||
torexitnodes.txt
|
torexitnodes.txt
|
||||||
|
files
|
||||||
|
thumbnails
|
||||||
|
db.sqlite3
|
||||||
|
config/config.yml
|
42
Dockerfile
Normal file
42
Dockerfile
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# Based on https://github.com/iv-org/invidious/blob/master/docker/Dockerfile
|
||||||
|
FROM crystallang/crystal:1.16.1-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache sqlite-static yaml-static
|
||||||
|
|
||||||
|
WORKDIR /file-uploader-crystal
|
||||||
|
|
||||||
|
COPY ./shard.yml ./shard.yml
|
||||||
|
COPY ./shard.lock ./shard.lock
|
||||||
|
RUN shards install --production
|
||||||
|
|
||||||
|
COPY ./src/ ./src/
|
||||||
|
# TODO: .git folder is required for building – this is destructive.
|
||||||
|
# See definition of CURRENT_BRANCH, CURRENT_COMMIT and CURRENT_VERSION.
|
||||||
|
COPY ./.git/ ./.git/
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/crystal \
|
||||||
|
crystal build ./src/file-uploader-crystal.cr \
|
||||||
|
--release \
|
||||||
|
--static --warnings all
|
||||||
|
|
||||||
|
# 2nd stage
|
||||||
|
FROM alpine:3.21
|
||||||
|
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
|
||||||
|
|
||||||
|
COPY --chown=file-uploader-crystal ./config/config.example.yml ./config/
|
||||||
|
RUN mv -n config/config.example.yml config/config.yml
|
||||||
|
COPY --from=builder /file-uploader-crystal/file-uploader-crystal /file-uploader-crystal
|
||||||
|
RUN chmod o+rX -R /file-uploader-crystal/file-uploader-crystal
|
||||||
|
RUN chown file-uploader-crystal: -R /file-uploader-crystal
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
USER file-uploader-crystal
|
||||||
|
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
|
||||||
|
CMD [ "/file-uploader-crystal/file-uploader-crystal" ]
|
25
README.md
25
README.md
|
@ -1,5 +1,8 @@
|
||||||
# file-uploader
|
# file-uploader
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Project being rewritten, some features like the admin API and some upload endpoints are unavailable on 0.9.5
|
||||||
|
|
||||||
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~~
|
~~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.
|
Already replaced lol.
|
||||||
|
@ -9,9 +12,9 @@ Already replaced lol.
|
||||||
- Temporary file uploads 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
|
||||||
- Video Thumbnails for Chatterino and FrankerFaceZ (Requires `ffmpeg` to be installed, can be disabled.)
|
- Video Thumbnails for Chatterino and FrankerFaceZ (Requires `ffmpeg` to be installed, disabled by default)
|
||||||
- Rate Limiting (WIP)
|
- Rate Limiting
|
||||||
- [Small Admin API](./src/handling/admin.cr) that allows you to delete files. (Needs to be enabled in the configuration)
|
- [Small Admin API](./src/handling/admin.cr) that allows you to delete files, reset rate limits and more (Needs to be enabled in the configuration)
|
||||||
- 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
|
||||||
- Automatic protocol detection (HTTPS or HTTP)
|
- 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.
|
- Low memory usage: Between 6MB at idle and 25MB if a file is being uploaded or retrieved. It will depend of your traffic.
|
||||||
|
@ -37,7 +40,7 @@ server {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
# This if you want to use a UNIX socket instead
|
# This if you want to use a UNIX socket instead
|
||||||
#proxy_pass http://unix:/tmp/file-uploader.sock;
|
#proxy_pass http://unix:/tmp/file-uploader.sock;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Real-IP $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_pass_request_headers on;
|
proxy_pass_request_headers on;
|
||||||
|
@ -69,17 +72,3 @@ WorkingDirectory=%h/file-uploader-crystal/
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
```
|
```
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- ~~Add file size limit~~ ADDED
|
|
||||||
- ~~Fix error when accessing `http://127.0.0.1:8080` with an empty DB.~~ Fixed somehow.
|
|
||||||
- Better frontend...
|
|
||||||
- ~~Disable file deletion if `deleteFilesCheck` or `deleteFilesAfter` is set to `0`~~ DONE
|
|
||||||
- ~~Disable delete key if `deleteKeyLength` is `0`~~ DONE (But I think there is a better way to do it)
|
|
||||||
- ~~Exit if `fileameLength` is `0`~~ DONE
|
|
||||||
- ~~Disable file limit if `size_limit` is `0`~~ DONE
|
|
||||||
- ~~Prevent files from being overwritten in the event of a name collision~~ DONE
|
|
||||||
- Dockerfile and Docker image (Crystal doesn't has dependency hell like other languages so is not really necessary to do, but useful for people that want instant deploy)
|
|
||||||
- Custom file expiration using headers (Like rustypaste)
|
|
||||||
- Small CLI to upload files (like `rpaste` from rustypaste)
|
|
||||||
|
|
43
config/config.example.yml
Normal file
43
config/config.example.yml
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
colorize_logs: true
|
||||||
|
log_level: "debug"
|
||||||
|
|
||||||
|
# File paths
|
||||||
|
files: "./files"
|
||||||
|
thumbnails: "./thumbnails"
|
||||||
|
db: "./db.sqlite3"
|
||||||
|
|
||||||
|
# Tor
|
||||||
|
block_tor_addresses: true
|
||||||
|
tor_exit_nodes_check: 1600
|
||||||
|
tor_exit_nodes_url: "https://check.torproject.org/exit-addresses"
|
||||||
|
tor_message: "TOR IS BLOCKED!"
|
||||||
|
|
||||||
|
generate_thumbnails: true
|
||||||
|
admin_enabled: true
|
||||||
|
admin_api_key: "asd"
|
||||||
|
size_limit: 512
|
||||||
|
enable_checksums: false
|
||||||
|
port: 8080
|
||||||
|
files_per_ip: 2
|
||||||
|
rate_limit_period: 20
|
||||||
|
rate_limit_message: ""
|
||||||
|
filename_length: 3
|
||||||
|
delete_files_after: 7
|
||||||
|
delete_files_check: 1800
|
||||||
|
delete_key_length: 4
|
||||||
|
|
||||||
|
site_info: "Whatever you want to put here"
|
||||||
|
site_warning: "WARNING!"
|
||||||
|
|
||||||
|
blocked_extensions:
|
||||||
|
- "exe"
|
||||||
|
|
||||||
|
opengraph_useragents:
|
||||||
|
- "chatterino-api-cache/"
|
||||||
|
- "FFZBot/"
|
||||||
|
- "Twitterbot/"
|
||||||
|
- "Synapse/"
|
||||||
|
- "Mastodon/"
|
||||||
|
|
||||||
|
# alternative_domains:
|
||||||
|
# - "example.com"
|
|
@ -1,40 +0,0 @@
|
||||||
files: "./files"
|
|
||||||
thumbnails: "./thumbnails"
|
|
||||||
generateThumbnails: false
|
|
||||||
db: "./db.sqlite3"
|
|
||||||
dbTableName: "files"
|
|
||||||
adminEnabled: false
|
|
||||||
adminApiKey: "asd"
|
|
||||||
fileameLength: 3
|
|
||||||
# In MiB
|
|
||||||
size_limit: 512
|
|
||||||
port: 8080
|
|
||||||
blockTorAddresses: true
|
|
||||||
# Every hour
|
|
||||||
torExitNodesCheck: 3600
|
|
||||||
torExitNodesUrl: "https://www.dan.me.uk/torlist/?exit"
|
|
||||||
torExitNodesFile: "./torexitnodes.txt"
|
|
||||||
torMessage: "TOR IS BLOCKED!"
|
|
||||||
# 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: 7
|
|
||||||
# In seconds
|
|
||||||
deleteFilesCheck: 1800
|
|
||||||
deleteKeyLength: 4
|
|
||||||
siteInfo: "Whatever you want to put here"
|
|
||||||
siteWarning: "WARNING!"
|
|
||||||
log_level: "debug"
|
|
||||||
|
|
||||||
blockedExtensions:
|
|
||||||
- "exe"
|
|
||||||
|
|
||||||
# List of useragents that use OpenGraph to gather file information
|
|
||||||
opengraphUseragents:
|
|
||||||
- "chatterino-api-cache/"
|
|
||||||
- "FFZBot/"
|
|
||||||
- "Twitterbot/"
|
|
||||||
|
|
||||||
alternativeDomains:
|
|
||||||
- "ayaya.beauty"
|
|
||||||
- "lamartina.gay"
|
|
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal 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
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
Before Width: | Height: | Size: 52 KiB |
Binary file not shown.
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 66 KiB |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
289
public/script.js
289
public/script.js
|
@ -1,140 +1,169 @@
|
||||||
|
// By chatgpt becuase I hate frontend and javascript kill me
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const dropArea = document.getElementById("drop-area");
|
const dropArea = document.getElementById("drop-area");
|
||||||
const fileInput = document.getElementById("fileElem");
|
const fileInput = document.getElementById("fileElem");
|
||||||
const uploadStatus = document.getElementById("upload-status");
|
const uploadStatus = document.getElementById("upload-status");
|
||||||
|
|
||||||
// Prevent default drag behaviors
|
// Prevent default drag behaviors
|
||||||
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
|
||||||
dropArea.addEventListener(eventName, preventDefaults, false);
|
dropArea.addEventListener(eventName, preventDefaults, false);
|
||||||
document.body.addEventListener(eventName, preventDefaults, false);
|
document.body.addEventListener(eventName, preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight drop area when item is dragged over
|
||||||
|
["dragenter", "dragover"].forEach((eventName) => {
|
||||||
|
dropArea.addEventListener(eventName, highlight, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
["dragleave", "drop"].forEach((eventName) => {
|
||||||
|
dropArea.addEventListener(eventName, unhighlight, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle dropped files
|
||||||
|
dropArea.addEventListener("drop", handleDrop, false);
|
||||||
|
dropArea.addEventListener("click", () => fileInput.click());
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
fileInput.addEventListener(
|
||||||
|
"change",
|
||||||
|
() => {
|
||||||
|
const files = fileInput.files;
|
||||||
|
handleFiles(files);
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle pasted files
|
||||||
|
document.addEventListener("paste", handlePaste, false);
|
||||||
|
|
||||||
|
function preventDefaults(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight() {
|
||||||
|
dropArea.classList.add("highlight");
|
||||||
|
}
|
||||||
|
|
||||||
|
function unhighlight() {
|
||||||
|
dropArea.classList.remove("highlight");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
const files = dt.files;
|
||||||
|
handleFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(e) {
|
||||||
|
const items = e.clipboardData.items;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.kind === "file") {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
handleFiles([file]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFiles(files) {
|
||||||
|
if (files.length > 0) {
|
||||||
|
for (const file of files) {
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(file) {
|
||||||
|
const url = "upload"; // Replace with your upload URL
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// Create a new upload status container and link elements
|
||||||
|
const uploadContainer = document.createElement("div");
|
||||||
|
const statusLink = document.createElement("div");
|
||||||
|
const uploadText = document.createElement("span");
|
||||||
|
const buttons = document.createElement("div");
|
||||||
|
const copyButton = document.createElement("button");
|
||||||
|
const deleteButton = document.createElement("button");
|
||||||
|
|
||||||
|
uploadContainer.className = "upload-status"; // Use the existing CSS class for styling
|
||||||
|
uploadContainer.appendChild(uploadText);
|
||||||
|
uploadContainer.appendChild(statusLink);
|
||||||
|
buttons.appendChild(copyButton)
|
||||||
|
buttons.appendChild(deleteButton)
|
||||||
|
uploadContainer.appendChild(buttons)
|
||||||
|
uploadStatus.appendChild(uploadContainer);
|
||||||
|
|
||||||
|
// Update upload text
|
||||||
|
uploadText.innerHTML = "0%";
|
||||||
|
uploadText.className = "percent";
|
||||||
|
statusLink.className = "status";
|
||||||
|
copyButton.className = "button copy-button"; // Add class for styling
|
||||||
|
copyButton.innerHTML = "Copiar"; // Set button text
|
||||||
|
deleteButton.className = "button delete-button";
|
||||||
|
deleteButton.innerHTML = "Borrar";
|
||||||
|
copyButton.style.display = "none";
|
||||||
|
deleteButton.style.display = "none";
|
||||||
|
|
||||||
|
// Update progress text
|
||||||
|
xhr.upload.addEventListener("progress", (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
||||||
|
uploadText.innerHTML = `${percentComplete}%`; // Update the text with the percentage
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Highlight drop area when item is dragged over
|
xhr.onerror = () => {
|
||||||
["dragenter", "dragover"].forEach(eventName => {
|
console.error("Error:", xhr.status, xhr.statusText, xhr.responseText);
|
||||||
dropArea.addEventListener(eventName, highlight, false);
|
statusLink.textContent = "Error desconocido";
|
||||||
});
|
};
|
||||||
|
|
||||||
["dragleave", "drop"].forEach(eventName => {
|
xhr.onload = () => {
|
||||||
dropArea.addEventListener(eventName, unhighlight, false);
|
// console.log("Response Status:", xhr.status);
|
||||||
});
|
// console.log("Response Text:", xhr.responseText);
|
||||||
|
if (xhr.status === 200) {
|
||||||
// Handle dropped files
|
try {
|
||||||
dropArea.addEventListener("drop", handleDrop, false);
|
const response = JSON.parse(xhr.responseText);
|
||||||
dropArea.addEventListener("click", () => fileInput.click());
|
const fileLink = response.link;
|
||||||
|
statusLink.innerHTML = `<a href="${fileLink}" target="_blank">${fileLink}</a>`;
|
||||||
// Handle file selection
|
copyButton.style.display = "inline";
|
||||||
fileInput.addEventListener("change", () => {
|
copyButton.onclick = () => copyToClipboard(fileLink);
|
||||||
const files = fileInput.files;
|
deleteButton.style.display = "inline";
|
||||||
handleFiles(files);
|
deleteButton.onclick = () => {
|
||||||
}, false);
|
window.open(response.deleteLink, "_blank");
|
||||||
|
};
|
||||||
// Handle pasted files
|
} catch (error) {
|
||||||
document.addEventListener("paste", handlePaste, false);
|
statusLink.textContent =
|
||||||
|
"Error desconocido, habla con el administrador";
|
||||||
function preventDefaults(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlight() {
|
|
||||||
dropArea.classList.add("highlight");
|
|
||||||
}
|
|
||||||
|
|
||||||
function unhighlight() {
|
|
||||||
dropArea.classList.remove("highlight");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e) {
|
|
||||||
const dt = e.dataTransfer;
|
|
||||||
const files = dt.files;
|
|
||||||
handleFiles(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaste(e) {
|
|
||||||
const items = e.clipboardData.items;
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i];
|
|
||||||
if (item.kind === "file") {
|
|
||||||
const file = item.getAsFile();
|
|
||||||
handleFiles([file]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
} else if (xhr.status >= 400 && xhr.status < 500) {
|
||||||
|
try {
|
||||||
function handleFiles(files) {
|
const errorResponse = JSON.parse(xhr.responseText);
|
||||||
if (files.length > 0) {
|
statusLink.textContent = errorResponse.error || "Error del cliente.";
|
||||||
for (const file of files) {
|
} catch (e) {
|
||||||
uploadFile(file);
|
statusLink.textContent = "Error del cliente.";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
statusLink.textContent = "Error del servidor.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function uploadFile(file) {
|
// Send file
|
||||||
const url = "upload"; // Replace with your upload URL
|
const formData = new FormData();
|
||||||
const xhr = new XMLHttpRequest();
|
formData.append("file", file);
|
||||||
|
xhr.open("POST", url, true);
|
||||||
|
xhr.send(formData);
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new upload status container and link elements
|
// Function to copy the link to the clipboard
|
||||||
const uploadContainer = document.createElement("div");
|
function copyToClipboard(text) {
|
||||||
const statusLink = document.createElement("div");
|
navigator.clipboard
|
||||||
const uploadText = document.createElement("span");
|
.writeText(text)
|
||||||
const copyButton = document.createElement("button");
|
.then(() => {
|
||||||
|
// alert("Link copied to clipboard!"); // Notify the user
|
||||||
uploadContainer.className = "upload-status"; // Use the existing CSS class for styling
|
})
|
||||||
uploadContainer.appendChild(uploadText);
|
.catch((err) => {
|
||||||
uploadContainer.appendChild(statusLink);
|
console.error("Failed to copy: ", err);
|
||||||
uploadContainer.appendChild(copyButton);
|
});
|
||||||
uploadStatus.appendChild(uploadContainer); // Append to the main upload status container
|
}
|
||||||
|
|
||||||
// Update upload text
|
|
||||||
uploadText.innerHTML = "0%";
|
|
||||||
uploadText.className = "percent"
|
|
||||||
copyButton.className = "copy-button"; // Add class for styling
|
|
||||||
copyButton.innerHTML = "Copiar"; // Set button text
|
|
||||||
copyButton.style.display = "none"; // Hide initially
|
|
||||||
|
|
||||||
// Update progress text
|
|
||||||
xhr.upload.addEventListener("progress", (e) => {
|
|
||||||
if (e.lengthComputable) {
|
|
||||||
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
|
||||||
uploadText.innerHTML = `${percentComplete}%`; // Update the text with the percentage
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle response
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status === 200) {
|
|
||||||
try {
|
|
||||||
const response = JSON.parse(xhr.responseText);
|
|
||||||
const fileLink = response.link; // Assuming the response contains a key 'link'
|
|
||||||
statusLink.innerHTML = `<a href="${fileLink}" target="_blank">${fileLink}</a>`;
|
|
||||||
copyButton.style.display = "inline"; // Show the copy button
|
|
||||||
copyButton.onclick = () => copyToClipboard(fileLink); // Set the copy action
|
|
||||||
} catch (error) {
|
|
||||||
statusLink.textContent = "File uploaded but failed to parse response.";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
statusLink.textContent = "File upload failed.";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle errors
|
|
||||||
xhr.onerror = () => {
|
|
||||||
statusLink.textContent = "An error occurred during the file upload.";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send file
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
xhr.open("POST", url, true);
|
|
||||||
xhr.send(formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to copy the link to the clipboard
|
|
||||||
function copyToClipboard(text) {
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
|
||||||
// alert("Link copied to clipboard!"); // Notify the user
|
|
||||||
}).catch(err => {
|
|
||||||
console.error("Failed to copy: ", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,172 +1,173 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "FG";
|
font-family: "FG";
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
src: url('framd.ttf');
|
src: url('framd.ttf');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "FG";
|
font-family: "FG";
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
src: url('frahv.ttf');
|
src: url('frahv.ttf');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "XFG";
|
font-family: "XFG";
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
src: url('frahvmod.ttf');
|
src: url('frahvmod.ttf');
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: "FG";
|
font-family: "FG";
|
||||||
background-image: linear-gradient(to bottom,
|
background: #111111;
|
||||||
rgba(11, 11, 11, 0.92),
|
background-attachment: fixed;
|
||||||
rgba(11, 11, 11, 0.92)),
|
background-repeat: no-repeat;
|
||||||
url(./bliss-small.avif);
|
background-size: cover;
|
||||||
background-attachment: fixed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
/* font-family: Arial, sans-serif; */
|
margin: 0;
|
||||||
/* background-color: #111; */
|
padding: 20px;
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p, h1, h2, h3, h4, h5 {
|
p,
|
||||||
color: aliceblue
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5 {
|
||||||
|
color: aliceblue
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-family: "FG";
|
font-family: "FG";
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
color: #ffb6c1
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
/* margin-top: 1ch;*/
|
flex: 1;
|
||||||
flex: 1;
|
text-align: center;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom > p {
|
.bottom>p {
|
||||||
margin: 10px 0px;
|
margin: 10px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.percent {
|
.percent {
|
||||||
color: aliceblue
|
color: aliceblue
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 700px;
|
max-width: 800px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
/* background: white; */
|
border-radius: 0px;
|
||||||
/*! padding: 20px; */
|
|
||||||
border-radius: 0px;
|
|
||||||
/*! box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container>img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 500px
|
||||||
|
}
|
||||||
|
|
||||||
#drop-area {
|
#drop-area {
|
||||||
/*! border: 2px solid #00ff00; */
|
text-align: center;
|
||||||
/*! border-radius: 6px; */
|
position: relative;
|
||||||
/*! padding-left: 10px; */
|
width: fit-content;
|
||||||
/*! padding-right: 10px; */
|
margin: 0 auto;
|
||||||
text-align: center;
|
display: block;
|
||||||
position: relative;
|
background: rgba(202, 230, 190, .75);
|
||||||
width: fit-content;
|
border: 1px solid #b7d1a0;
|
||||||
margin: 0 auto; /* Center the element */
|
border-radius: 4px;
|
||||||
display: block; /* Ensure it behaves as a block-level element */
|
color: #468847;
|
||||||
background: rgba(202,230,190,.75);
|
cursor: pointer;
|
||||||
border: 1px solid #b7d1a0;
|
font-size: 24px;
|
||||||
border-radius: 4px;
|
padding: 28px 48px;
|
||||||
color: #468847;
|
text-shadow: 0 1px hsla(0, 0%, 100%, .5);
|
||||||
cursor: pointer;
|
transition: background-color .25s, width .5s, height .5s;
|
||||||
/*! display: inline-block; */
|
|
||||||
font-size: 24px;
|
|
||||||
padding: 28px 48px;
|
|
||||||
text-shadow: 0 1px hsla(0,0%,100%,.5);
|
|
||||||
transition: background-color .25s,width .5s,height .5s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
/* background: #; */
|
color: white;
|
||||||
color: white;
|
border-radius: 5px;
|
||||||
border-radius: 5px;
|
cursor: pointer;
|
||||||
cursor: pointer;
|
|
||||||
/* margin-top: 10px; */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-status {
|
.upload-status {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a, nav > ul
|
nav a,
|
||||||
{
|
nav>ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#upload-status {
|
#upload-status {
|
||||||
margin: 20px; /* Adjust as needed */
|
margin: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-status {
|
.upload-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border: 2px solid #999; /* Optional styling for the status box */
|
border: 1px solid #ffffff;
|
||||||
padding: 5px; /* Optional padding */
|
padding: 5px;
|
||||||
/*! border-radius: 6px; */ /* Optional rounded corners */
|
|
||||||
/*! background-color: #f9f9f9; */ /* Optional background color */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-container {
|
.link-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: auto; /* Pushes the link and button to the right */
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
color: #ffb6c1;
|
color: #ffb6c1;
|
||||||
text-decoration: none; /* Remove underline from link */
|
text-decoration: none;
|
||||||
margin-right: 5px; /* Space between link and button */
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link:hover {
|
.link:hover {
|
||||||
text-decoration: underline; /* Optional: underline on hover */
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline;
|
||||||
|
color: rgb(255, 255, 255);
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-button {
|
.copy-button {
|
||||||
display: inline;
|
background-color: #1e8a1a;
|
||||||
background-color: #5e5e5e; /* Button background color */
|
}
|
||||||
color: white; /* Button text color */
|
|
||||||
border: none; /* Remove border */
|
.delete-button {
|
||||||
border-radius: 3px; /* Rounded corners for the button */
|
background-color: #b83434;
|
||||||
padding: 5px 10px; /* Button padding */
|
margin-left: 6px
|
||||||
cursor: pointer; /* Pointer cursor on hover */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-button:hover {
|
.copy-button:hover {
|
||||||
background-color: #404040; /* Darker shade on hover */
|
background-color: #156412;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:link {
|
.delete-button:hover {
|
||||||
color: #ffb6c1
|
background-color: #912a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:visited {
|
.status {
|
||||||
color: #ffb6c1
|
color: rgb(255, 132, 0);
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #ffb6c1
|
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
name: file-uploader
|
name: file-uploader-crystal
|
||||||
version: 0.8.7
|
version: 0.8.7
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
|
@ -6,7 +6,7 @@ authors:
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
file-uploader:
|
file-uploader:
|
||||||
main: src/file-uploader.cr
|
main: src/file-uploader-crystal.cr
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
kemal:
|
kemal:
|
||||||
|
|
119
src/config.cr
119
src/config.cr
|
@ -2,58 +2,107 @@ require "yaml"
|
||||||
|
|
||||||
class Config
|
class Config
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
# Colorize logs
|
||||||
|
property colorize_logs : Bool = true
|
||||||
|
# Log level
|
||||||
|
property log_level : LogLevel = LogLevel::Info
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Where the uploaded files will be located
|
||||||
property files : String = "./files"
|
property files : String = "./files"
|
||||||
|
# Where the thumbnails will be located when they are successfully generated
|
||||||
property thumbnails : String = "./thumbnails"
|
property thumbnails : String = "./thumbnails"
|
||||||
property generateThumbnails : Bool = false
|
# Generate thumbnails for OpenGraph compatible platforms like Chatterino
|
||||||
|
# Whatsapp, Facebook, Discord, etc.
|
||||||
|
property generate_thumbnails : Bool = false
|
||||||
|
# Where the SQLITE3 database will be located
|
||||||
property db : String = "./db.sqlite3"
|
property db : String = "./db.sqlite3"
|
||||||
property dbTableName : String = "files"
|
|
||||||
property adminEnabled : Bool = false
|
# Enable or disable the admin API
|
||||||
property adminApiKey : String? = ""
|
property admin_enabled : Bool = false
|
||||||
|
# The API key for admin routes. It's passed as a "X-Api-Key" header to the
|
||||||
|
# request
|
||||||
|
property admin_api_key : String? = nil
|
||||||
|
|
||||||
# Not implemented
|
# Not implemented
|
||||||
property incrementalFileameLength : Bool = true
|
property incrementalfilename_length : Bool = true
|
||||||
property fileameLength : Int32 = 3
|
# Filename length
|
||||||
|
property filename_length : Int32 = 3
|
||||||
# In MiB
|
# In MiB
|
||||||
property size_limit : Int16 = 512
|
property size_limit : Int16 = 512
|
||||||
property port : Int32 = 8080
|
property enable_checksums : Bool = true
|
||||||
|
|
||||||
|
# A file path where do you want to place a unix socket (THIS WILL DISABLE ACCESS
|
||||||
|
# BY IP ADDRESS)
|
||||||
property unix_socket : String?
|
property unix_socket : String?
|
||||||
property blockTorAddresses : Bool? = false
|
|
||||||
property torExitNodesCheck : Int32 = 3600
|
# True if you want this program to block IP addresses coming from the Tor
|
||||||
# The list needs to contain a IP address per line
|
# network
|
||||||
property torExitNodesUrl : String = "https://www.dan.me.uk/torlist/?exit"
|
property block_tor_addresses : Bool = false
|
||||||
property torExitNodesFile : String = "./torexitnodes.txt"
|
# How often (in seconds) should this program download the exit nodes list
|
||||||
property filesPerIP : Int32 = 32
|
property tor_exit_nodes_check : Int32 = 3600
|
||||||
property ipTableName : String = "ips"
|
# Only https://check.torproject.org/exit-addresses is supported
|
||||||
# How often is the file limit per IP reset?
|
property tor_exit_nodes_url : String = "https://check.torproject.org/exit-addresses"
|
||||||
property rateLimitPeriod : Int32 = 600
|
# Message that will be displayed to the Tor user.
|
||||||
property torMessage : String? = "Tor is blocked!"
|
# It will be shown on the Frontend and shown in the error 401 when a user
|
||||||
property deleteFilesAfter : Int32 = 7
|
# tries to upload a file using curl or any other tool
|
||||||
|
property tor_message : String? = "Tor is blocked!"
|
||||||
|
|
||||||
|
# How many files an IP address can upload to the server. Setting this to 0
|
||||||
|
# disables rate limits in the rate limit period
|
||||||
|
property files_per_ip : Int32 = 32
|
||||||
|
# How often is the file limit per IP reset? (in seconds)
|
||||||
|
property rate_limit_period : Int32 = 600
|
||||||
|
# TODO: UNUSED CONSTANT
|
||||||
|
property rate_limit_message : String = ""
|
||||||
|
|
||||||
|
# Delete the files after how many days?
|
||||||
|
property delete_files_after : Int32 = 14
|
||||||
# How often should the check of old files be performed? (in seconds)
|
# How often should the check of old files be performed? (in seconds)
|
||||||
property deleteFilesCheck : Int32 = 1800
|
property delete_files_check : Int32 = 1800
|
||||||
property deleteKeyLength : Int32 = 4
|
# The lenght of the delete key
|
||||||
|
property delete_key_length : Int32 = 6
|
||||||
|
|
||||||
|
property site_info : String = "xd"
|
||||||
|
# TODO: UNUSED CONSTANT
|
||||||
|
property site_warning : String? = ""
|
||||||
|
|
||||||
# Blocked extensions that are not allowed to be uploaded to the server
|
# Blocked extensions that are not allowed to be uploaded to the server
|
||||||
property siteInfo : String = "xd"
|
property blocked_extensions : Array(String) = [] of String
|
||||||
property siteWarning : String? = ""
|
|
||||||
property log_level : LogLevel = LogLevel::Info
|
# A list of OpenGraph user agents. If the request contains one of those User
|
||||||
property blockedExtensions : Array(String) = [] of String
|
# agents when trying to retrieve a file from the server; the server will
|
||||||
property opengraphUseragents : Array(String) = [] of String
|
# reply with an HTML with OpenGraph tags, pointing to the media thumbnail
|
||||||
|
# (if it was generated successfully) and the name of the file as title
|
||||||
|
property opengraph_useragents : Array(String) = [] of String
|
||||||
|
|
||||||
# Since this program detects the Host header of the client it can be used
|
# Since this program detects the Host header of the client it can be used
|
||||||
# with multiple domains. You can display the domains in the frontend
|
# with multiple domains. You can display the domains in the frontend
|
||||||
# and in `/api/stats`
|
# and in `/api/stats`
|
||||||
property alternativeDomains : Array(String) = [] of String
|
property alternative_domains : Array(String) = [] of String
|
||||||
|
|
||||||
def self.load
|
def self.check_config(config : Config)
|
||||||
config_file = "config/config.yml"
|
if config.filename_length <= 0
|
||||||
|
puts "Config: filename_length cannot be less or equal to 0"
|
||||||
|
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
|
||||||
|
|
||||||
|
def self.load(config_file : String = "config/config.yml")
|
||||||
config_yaml = File.read(config_file)
|
config_yaml = File.read(config_file)
|
||||||
config = Config.from_yaml(config_yaml)
|
config = Config.from_yaml(config_yaml)
|
||||||
check_config(config)
|
check_config(config)
|
||||||
config
|
config
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.check_config(config : Config)
|
|
||||||
if config.fileameLength <= 0
|
|
||||||
puts "Config: fileameLength cannot be #{config.fileameLength}"
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
83
src/database/files.cr
Normal file
83
src/database/files.cr
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
module Database::Files
|
||||||
|
extend self
|
||||||
|
|
||||||
|
# -------------------
|
||||||
|
# Insert / Delete
|
||||||
|
# -------------------
|
||||||
|
|
||||||
|
def insert(file : UFile) : Nil
|
||||||
|
request = <<-SQL
|
||||||
|
INSERT INTO files
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.exec(request, *file.to_tuple)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(filename : String) : Nil
|
||||||
|
request = <<-SQL
|
||||||
|
DELETE
|
||||||
|
FROM files
|
||||||
|
WHERE filename = ?
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.exec(request, filename)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_with_key(key : String) : Nil
|
||||||
|
request = <<-SQL
|
||||||
|
DELETE FROM files
|
||||||
|
WHERE delete_key = ?
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.exec(request, key)
|
||||||
|
end
|
||||||
|
|
||||||
|
# -------------------
|
||||||
|
# Select
|
||||||
|
# -------------------
|
||||||
|
|
||||||
|
def select(filename : String) : UFile?
|
||||||
|
request = <<-SQL
|
||||||
|
SELECT *
|
||||||
|
FROM files
|
||||||
|
WHERE filename = ?
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.query_one?(request, filename, as: UFile)
|
||||||
|
end
|
||||||
|
|
||||||
|
def select_with_key(delete_key : String) : UFile?
|
||||||
|
request = <<-SQL
|
||||||
|
SELECT *
|
||||||
|
FROM files
|
||||||
|
WHERE delete_key = ?
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.query_one?(request, delete_key, as: UFile)
|
||||||
|
end
|
||||||
|
|
||||||
|
# -------------------
|
||||||
|
# Misc
|
||||||
|
# -------------------
|
||||||
|
|
||||||
|
def old_files : Array(UFile)
|
||||||
|
request = <<-SQL
|
||||||
|
SELECT *
|
||||||
|
FROM files
|
||||||
|
WHERE uploaded_at < strftime('%s', 'now') - #{CONFIG.delete_files_after * 3600}
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.query_all(request, as: UFile)
|
||||||
|
end
|
||||||
|
|
||||||
|
def file_count : Int32
|
||||||
|
request = <<-SQL
|
||||||
|
SELECT COUNT (filename)
|
||||||
|
FROM files
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.query_one(request, as: Int32)
|
||||||
|
end
|
||||||
|
end
|
55
src/database/ip.cr
Normal file
55
src/database/ip.cr
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
module Database::IP
|
||||||
|
extend self
|
||||||
|
|
||||||
|
# -------------------
|
||||||
|
# Insert / Delete
|
||||||
|
# -------------------
|
||||||
|
|
||||||
|
def insert(ip : UIP) : DB::ExecResult
|
||||||
|
request = <<-SQL
|
||||||
|
INSERT OR IGNORE
|
||||||
|
INTO ips
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.exec(request, *ip.to_tuple)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(ip : String) : Nil
|
||||||
|
request = <<-SQL
|
||||||
|
DELETE
|
||||||
|
FROM ips
|
||||||
|
WHERE ip = ?
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.exec(request, ip)
|
||||||
|
end
|
||||||
|
|
||||||
|
# -------------------
|
||||||
|
# Select
|
||||||
|
# -------------------
|
||||||
|
|
||||||
|
def select(ip : String) : UIP?
|
||||||
|
request = <<-SQL
|
||||||
|
SELECT *
|
||||||
|
FROM ips
|
||||||
|
WHERE ip = ?
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.query_one?(request, ip, as: UIP)
|
||||||
|
end
|
||||||
|
|
||||||
|
# -------------------
|
||||||
|
# Update
|
||||||
|
# -------------------
|
||||||
|
|
||||||
|
def increase_count(ip : UIP) : Nil
|
||||||
|
request = <<-SQL
|
||||||
|
UPDATE ips
|
||||||
|
SET count = count + 1
|
||||||
|
WHERE ip = $1
|
||||||
|
SQL
|
||||||
|
|
||||||
|
SQL.exec(request, ip.ip)
|
||||||
|
end
|
||||||
|
end
|
|
@ -7,20 +7,22 @@ require "digest"
|
||||||
|
|
||||||
require "./logger"
|
require "./logger"
|
||||||
require "./routing"
|
require "./routing"
|
||||||
require "./utils"
|
|
||||||
require "./handling/**"
|
|
||||||
require "./config"
|
require "./config"
|
||||||
require "./jobs"
|
require "./jobs"
|
||||||
require "./lib/**"
|
require "./utils/*"
|
||||||
|
require "./lib/*"
|
||||||
|
require "./types/*"
|
||||||
|
require "./database/*"
|
||||||
|
|
||||||
CONFIG = Config.load
|
CONFIG = Config.load
|
||||||
Kemal.config.port = CONFIG.port
|
Kemal.config.port = CONFIG.port
|
||||||
|
Kemal.config.host_binding = CONFIG.host
|
||||||
Kemal.config.shutdown_message = false
|
Kemal.config.shutdown_message = false
|
||||||
Kemal.config.app_name = "file-uploader-crystal"
|
Kemal.config.app_name = "file-uploader-crystal"
|
||||||
# https://github.com/iv-org/invidious/blob/90e94d4e6cc126a8b7a091d12d7a5556bfe369d5/src/invidious.cr#L136C1-L136C61
|
# 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
|
# Give me a 128 bit CPU
|
||||||
# MAX_FILES = 58**CONFIG.fileameLength
|
# MAX_FILES = 58**CONFIG.filename_length
|
||||||
SQL = DB.open("sqlite3://#{CONFIG.db}")
|
SQL = DB.open("sqlite3://#{CONFIG.db}")
|
||||||
|
|
||||||
# https://github.com/iv-org/invidious/blob/90e94d4e6cc126a8b7a091d12d7a5556bfe369d5/src/invidious.cr#L78
|
# https://github.com/iv-org/invidious/blob/90e94d4e6cc126a8b7a091d12d7a5556bfe369d5/src/invidious.cr#L78
|
||||||
|
@ -32,6 +34,7 @@ CURRENT_TAG = {{ "#{`git describe --long --abbrev=7 --tags | sed 's/([^-]*)-
|
||||||
Utils.check_dependencies
|
Utils.check_dependencies
|
||||||
Utils.create_db
|
Utils.create_db
|
||||||
Utils.create_files_dir
|
Utils.create_files_dir
|
||||||
|
Utils.create_thumbnails_dir
|
||||||
Routing.register_all
|
Routing.register_all
|
||||||
|
|
||||||
Utils.delete_socket
|
Utils.delete_socket
|
|
@ -1,49 +0,0 @@
|
||||||
require "../http-errors"
|
|
||||||
|
|
||||||
module Handling::Admin
|
|
||||||
extend self
|
|
||||||
|
|
||||||
def delete_file(env)
|
|
||||||
if env.request.headers.try &.["X-Api-Key"]? != CONFIG.adminApiKey || nil
|
|
||||||
error401 "Wrong API Key"
|
|
||||||
end
|
|
||||||
files = env.params.json["files"].as((Array(JSON::Any)))
|
|
||||||
successfull_files = [] of String
|
|
||||||
failed_files = [] of String
|
|
||||||
files.each do |file|
|
|
||||||
file = file.to_s
|
|
||||||
begin
|
|
||||||
fileinfo = SQL.query_one("SELECT filename, extension, thumbnail
|
|
||||||
FROM #{CONFIG.dbTableName}
|
|
||||||
WHERE filename = ?",
|
|
||||||
file,
|
|
||||||
as: {filename: String, extension: String, thumbnail: String | Nil})
|
|
||||||
|
|
||||||
# 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 filename = ?", file
|
|
||||||
LOGGER.debug "File '#{fileinfo[:filename]}' was deleted"
|
|
||||||
successfull_files << file
|
|
||||||
rescue ex : DB::NoResultsError
|
|
||||||
LOGGER.error("File '#{file}' doesn't exist or is not registered in the database: #{ex.message}")
|
|
||||||
failed_files << file
|
|
||||||
rescue ex
|
|
||||||
LOGGER.error "Unknown error: #{ex.message}"
|
|
||||||
error500 "Unknown error: #{ex.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
json = JSON.build do |j|
|
|
||||||
j.object do
|
|
||||||
j.field "successfull", successfull_files.size
|
|
||||||
j.field "failed", failed_files.size
|
|
||||||
j.field "successfullFiles", successfull_files
|
|
||||||
j.field "failedFiles", failed_files
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,288 +0,0 @@
|
||||||
require "../http-errors"
|
|
||||||
require "http/client"
|
|
||||||
|
|
||||||
module Handling
|
|
||||||
extend self
|
|
||||||
|
|
||||||
def upload(env)
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
ip_address = Utils.ip_address(env)
|
|
||||||
protocol = Utils.protocol(env)
|
|
||||||
host = Utils.host(env)
|
|
||||||
# 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")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
filename = ""
|
|
||||||
extension = ""
|
|
||||||
original_filename = ""
|
|
||||||
uploaded_at = ""
|
|
||||||
checksum = ""
|
|
||||||
delete_key = nil
|
|
||||||
# TODO: Return the file that matches a checksum inside the database
|
|
||||||
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")
|
|
||||||
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")
|
|
||||||
end
|
|
||||||
filename = Utils.generate_filename
|
|
||||||
file_path = ::File.join ["#{CONFIG.files}", filename + extension]
|
|
||||||
File.open(file_path, "w") do |output|
|
|
||||||
IO.copy(upload.body, output)
|
|
||||||
end
|
|
||||||
original_filename = upload.filename
|
|
||||||
uploaded_at = Time::Format::HTTP_DATE.format(Time.utc)
|
|
||||||
checksum = Utils.hash_file(file_path)
|
|
||||||
end
|
|
||||||
# X-Forwarded-For if behind a reverse proxy and the header is set in the reverse
|
|
||||||
# proxy configuration.
|
|
||||||
json = JSON.build do |j|
|
|
||||||
j.object do
|
|
||||||
j.field "link", "#{protocol}://#{host}/#{filename}"
|
|
||||||
j.field "linkExt", "#{protocol}://#{host}/#{filename}#{extension}"
|
|
||||||
j.field "id", filename
|
|
||||||
j.field "ext", extension
|
|
||||||
j.field "name", original_filename
|
|
||||||
j.field "checksum", checksum
|
|
||||||
if CONFIG.deleteKeyLength > 0
|
|
||||||
delete_key = Random.base58(CONFIG.deleteKeyLength)
|
|
||||||
j.field "deleteKey", delete_key
|
|
||||||
j.field "deleteLink", "#{protocol}://#{host}/delete?key=#{delete_key}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
spawn { Utils.generate_thumbnail(filename, extension) }
|
|
||||||
rescue ex
|
|
||||||
LOGGER.error "An error ocurred when trying to generate a thumbnail: #{ex.message}"
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
# Insert SQL data just before returning the upload information
|
|
||||||
SQL.exec "INSERT INTO #{CONFIG.dbTableName} VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
original_filename, filename, extension, uploaded_at, checksum, ip_address, delete_key, nil
|
|
||||||
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}')"
|
|
||||||
|
|
||||||
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")
|
|
||||||
end
|
|
||||||
json
|
|
||||||
end
|
|
||||||
|
|
||||||
# The most unoptimized and unstable feature lol
|
|
||||||
# TODO: Support batch upload via JSON array
|
|
||||||
def upload_url(env)
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
ip_address = Utils.ip_address(env)
|
|
||||||
protocol = Utils.protocol(env)
|
|
||||||
host = Utils.host(env)
|
|
||||||
files = env.params.json["files"].as((Array(JSON::Any)))
|
|
||||||
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
|
|
||||||
original_filename = ""
|
|
||||||
extension = ""
|
|
||||||
checksum = ""
|
|
||||||
uploaded_at = Time::Format::HTTP_DATE.format(Time.utc)
|
|
||||||
extension = File.extname(URI.parse(url).path)
|
|
||||||
delete_key = nil
|
|
||||||
file_path = ::File.join ["#{CONFIG.files}", filename + extension]
|
|
||||||
File.open(file_path, "w") do |output|
|
|
||||||
begin
|
|
||||||
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}'")
|
|
||||||
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]
|
|
||||||
end
|
|
||||||
# TODO: Benchmark this:
|
|
||||||
# original_filename = URI.parse("https://ayaya.beauty/PqC").path.split("/").last
|
|
||||||
original_filename = url.split("/").last
|
|
||||||
checksum = Utils.hash_file(file_path)
|
|
||||||
begin
|
|
||||||
spawn { Utils.generate_thumbnail(filename, extension) }
|
|
||||||
rescue ex
|
|
||||||
LOGGER.error "An error ocurred when trying to generate a thumbnail: #{ex.message}"
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
# Insert SQL data just before returning the upload information
|
|
||||||
SQL.exec("INSERT INTO #{CONFIG.dbTableName} VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
original_filename, filename, extension, uploaded_at, checksum, ip_address, delete_key, nil)
|
|
||||||
successfull_files << {filename: filename,
|
|
||||||
original_filename: original_filename,
|
|
||||||
extension: extension,
|
|
||||||
delete_key: delete_key,
|
|
||||||
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")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
json = JSON.build do |j|
|
|
||||||
j.array do
|
|
||||||
successfull_files.each do |fileinfo|
|
|
||||||
j.object do
|
|
||||||
j.field "link", "#{protocol}://#{host}/#{fileinfo[:filename]}"
|
|
||||||
j.field "linkExt", "#{protocol}://#{host}/#{fileinfo[:filename]}#{fileinfo[:extension]}"
|
|
||||||
j.field "id", fileinfo[:filename]
|
|
||||||
j.field "ext", fileinfo[:extension]
|
|
||||||
j.field "name", fileinfo[:original_filename]
|
|
||||||
j.field "checksum", fileinfo[:checksum]
|
|
||||||
if CONFIG.deleteKeyLength > 0
|
|
||||||
delete_key = Random.base58(CONFIG.deleteKeyLength)
|
|
||||||
j.field "deleteKey", fileinfo[:delete_key]
|
|
||||||
j.field "deleteLink", "#{protocol}://#{host}/delete?key=#{fileinfo[:delete_key]}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
json
|
|
||||||
end
|
|
||||||
|
|
||||||
def retrieve_file(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}
|
|
||||||
WHERE filename = ?",
|
|
||||||
env.params.url["filename"].split(".").first,
|
|
||||||
as: {filename: String, ofilename: String, up_at: String, ext: String, checksum: String, thumbnail: String | Nil})[0]
|
|
||||||
|
|
||||||
headers(env, {"Content-Disposition" => "inline; filename*=UTF-8''#{fileinfo[:ofilename]}"})
|
|
||||||
headers(env, {"Last-Modified" => "#{fileinfo[:up_at]}"})
|
|
||||||
headers(env, {"ETag" => "#{fileinfo[:checksum]}"})
|
|
||||||
|
|
||||||
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>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta property="og:title" content="#{fileinfo[:ofilename]}">
|
|
||||||
<meta property="og:url" content="#{protocol}://#{host}/#{fileinfo[:filename]}">
|
|
||||||
#{if fileinfo[:thumbnail]
|
|
||||||
%(<meta property="og:image" content="#{protocol}://#{host}/thumbnail/#{fileinfo[:filename]}.jpg">)
|
|
||||||
end}
|
|
||||||
</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
|
|
||||||
end
|
|
||||||
|
|
||||||
def retrieve_thumbnail(env)
|
|
||||||
begin
|
|
||||||
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")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def stats(env)
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
begin
|
|
||||||
json_data = JSON.build do |json|
|
|
||||||
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 "maxUploadSize", CONFIG.size_limit
|
|
||||||
json.field "thumbnailGeneration", CONFIG.generateThumbnails
|
|
||||||
json.field "filenameLength", CONFIG.fileameLength
|
|
||||||
json.field "alternativeDomains", CONFIG.alternativeDomains
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue ex
|
|
||||||
LOGGER.error "Unknown error: #{ex.message}"
|
|
||||||
return error500("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
|
|
||||||
begin
|
|
||||||
fileinfo = SQL.query_all("SELECT filename, extension, thumbnail
|
|
||||||
FROM #{CONFIG.dbTableName}
|
|
||||||
WHERE delete_key = ?",
|
|
||||||
env.params.query["key"],
|
|
||||||
as: {filename: String, extension: String, thumbnail: String | Nil})[0]
|
|
||||||
|
|
||||||
# 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"]
|
|
||||||
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")
|
|
||||||
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")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sharex_config(env)
|
|
||||||
host = Utils.host(env)
|
|
||||||
protocol = Utils.protocol(env)
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
# So it's able to download the file instead of displaying it
|
|
||||||
env.response.headers["Content-Disposition"] = "attachment; filename=\"#{host}.sxcu\""
|
|
||||||
return %({
|
|
||||||
"Version": "14.0.1",
|
|
||||||
"DestinationType": "ImageUploader, FileUploader",
|
|
||||||
"RequestMethod": "POST",
|
|
||||||
"RequestURL": "#{protocol}://#{host}/upload",
|
|
||||||
"Body": "MultipartFormData",
|
|
||||||
"FileFormName": "file",
|
|
||||||
"URL": "{json:link}",
|
|
||||||
"DeletionURL": "{json:deleteLink}",
|
|
||||||
"ErrorMessage": "{json:error}"
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,40 +0,0 @@
|
||||||
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 msg(message)
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
msg = {"message" => {{message}}}.to_json
|
|
||||||
msg
|
|
||||||
end
|
|
15
src/jobs.cr
15
src/jobs.cr
|
@ -1,26 +1,27 @@
|
||||||
# Pretty cool way to write background jobs! :)
|
# Pretty cool way to write background jobs! :)
|
||||||
module Jobs
|
module Jobs
|
||||||
def self.check_old_files
|
def self.check_old_files
|
||||||
if CONFIG.deleteFilesCheck <= 0
|
if CONFIG.delete_files_check <= 0
|
||||||
LOGGER.info "File deletion is disabled"
|
LOGGER.info "File deletion is disabled"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
spawn do
|
spawn do
|
||||||
loop do
|
loop do
|
||||||
Utils.check_old_files
|
Utils.check_old_files
|
||||||
sleep CONFIG.deleteFilesCheck
|
sleep CONFIG.delete_files_check.seconds
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.retrieve_tor_exit_nodes
|
def self.retrieve_tor_exit_nodes
|
||||||
if !CONFIG.blockTorAddresses
|
if !CONFIG.block_tor_addresses
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
LOGGER.info("Blocking Tor exit nodes")
|
||||||
spawn do
|
spawn do
|
||||||
loop do
|
loop do
|
||||||
Utils.retrieve_tor_exit_nodes
|
Utils::Tor.refresh_exit_nodes
|
||||||
sleep CONFIG.torExitNodesCheck
|
sleep CONFIG.tor_exit_nodes_check.seconds
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -28,9 +29,7 @@ module Jobs
|
||||||
def self.kemal
|
def self.kemal
|
||||||
spawn do
|
spawn do
|
||||||
if !CONFIG.unix_socket.nil?
|
if !CONFIG.unix_socket.nil?
|
||||||
Kemal.run do |config|
|
Kemal.run &.server.not_nil!.bind_unix "#{CONFIG.unix_socket}"
|
||||||
config.server.not_nil!.bind_unix "#{CONFIG.unix_socket}"
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
Kemal.run
|
Kemal.run
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
module LOGGER
|
|
||||||
end
|
|
|
@ -1,4 +1,6 @@
|
||||||
# https://github.com/iv-org/invidious/blob/master/src/invidious/helpers/logger.cr
|
# https://github.com/iv-org/invidious/blob/master/src/invidious/helpers/logger.cr
|
||||||
|
require "colorize"
|
||||||
|
|
||||||
enum LogLevel
|
enum LogLevel
|
||||||
All = 0
|
All = 0
|
||||||
Trace = 1
|
Trace = 1
|
||||||
|
@ -11,7 +13,9 @@ enum LogLevel
|
||||||
end
|
end
|
||||||
|
|
||||||
class LogHandler < Kemal::BaseLogHandler
|
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
|
end
|
||||||
|
|
||||||
def call(context : HTTP::Server::Context)
|
def call(context : HTTP::Server::Context)
|
||||||
|
@ -21,42 +25,32 @@ class LogHandler < Kemal::BaseLogHandler
|
||||||
# Default: full path with parameters
|
# Default: full path with parameters
|
||||||
requested_url = context.request.resource
|
requested_url = context.request.resource
|
||||||
|
|
||||||
# Try not to log search queries passed as GET parameters during normal use
|
|
||||||
# (They will still be logged if log level is 'Debug' or 'Trace')
|
|
||||||
if @level > LogLevel::Debug && (
|
|
||||||
requested_url.downcase.includes?("search") || requested_url.downcase.includes?("q=")
|
|
||||||
)
|
|
||||||
# Log only the path
|
|
||||||
requested_url = context.request.path
|
|
||||||
end
|
|
||||||
|
|
||||||
info("#{context.response.status_code} #{context.request.method} #{requested_url} #{elapsed_text}")
|
info("#{context.response.status_code} #{context.request.method} #{requested_url} #{elapsed_text}")
|
||||||
|
|
||||||
context
|
context
|
||||||
end
|
end
|
||||||
|
|
||||||
def puts(message : String)
|
|
||||||
@io << message << '\n'
|
|
||||||
@io.flush
|
|
||||||
end
|
|
||||||
|
|
||||||
def write(message : String)
|
def write(message : String)
|
||||||
@io << message
|
@io << message
|
||||||
@io.flush
|
@io.flush
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_log_level(level : String)
|
def color(level)
|
||||||
@level = LogLevel.parse(level)
|
case level
|
||||||
end
|
when LogLevel::Trace then :cyan
|
||||||
|
when LogLevel::Debug then :green
|
||||||
def set_log_level(level : LogLevel)
|
when LogLevel::Info then :white
|
||||||
@level = level
|
when LogLevel::Warn then :yellow
|
||||||
|
when LogLevel::Error then :red
|
||||||
|
when LogLevel::Fatal then :magenta
|
||||||
|
else :default
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
{% for level in %w(trace debug info warn error fatal) %}
|
{% for level in %w(trace debug info warn error fatal) %}
|
||||||
def {{level.id}}(message : String)
|
def {{level.id}}(message : String)
|
||||||
if LogLevel::{{level.id.capitalize}} >= @level
|
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
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
26
src/macros.cr
Normal file
26
src/macros.cr
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
macro ee(status_code, message)
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
env.response.status_code = {{status_code}}
|
||||||
|
msg = {"error" => {{message}}}.to_json
|
||||||
|
return msg
|
||||||
|
end
|
||||||
|
|
||||||
|
macro msg(message)
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
msg = {"message" => {{message}}}.to_json
|
||||||
|
return msg
|
||||||
|
end
|
||||||
|
|
||||||
|
module Headers
|
||||||
|
macro host
|
||||||
|
env.request.headers["X-Forwarded-Host"]? || env.request.headers["Host"]?
|
||||||
|
end
|
||||||
|
|
||||||
|
macro scheme
|
||||||
|
env.request.headers["X-Forwarded-Proto"]? || "http"
|
||||||
|
end
|
||||||
|
|
||||||
|
macro ip_addr
|
||||||
|
env.request.headers["X-Real-IP"]? || env.request.remote_address.as?(Socket::IPAddress).try &.address
|
||||||
|
end
|
||||||
|
end
|
172
src/routes/admin.cr
Normal file
172
src/routes/admin.cr
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
module Routes::Admin
|
||||||
|
extend self
|
||||||
|
|
||||||
|
# private macro json_fill(named_tuple, field_name)
|
||||||
|
# j.field {{field_name}}, {{named_tuple}}[:{{field_name}}]
|
||||||
|
# end
|
||||||
|
|
||||||
|
# /api/admin/delete
|
||||||
|
# curl -X POST -H "Content-Type: application/json" -H "X-Api-Key: asd" http://localhost:8080/api/admin/delete -d '{"files": ["j63"]}' | jq
|
||||||
|
def delete_file(env)
|
||||||
|
files = env.params.json["files"].as((Array(JSON::Any)))
|
||||||
|
successfull_files = [] of String
|
||||||
|
failed_files = [] of String
|
||||||
|
files.each do |file|
|
||||||
|
file = file.to_s
|
||||||
|
begin
|
||||||
|
fileinfo = SQL.query_one("SELECT filename, extension, thumbnail
|
||||||
|
FROM files
|
||||||
|
WHERE filename = ?",
|
||||||
|
file,
|
||||||
|
as: {filename: String, extension: String, thumbnail: String | Nil})
|
||||||
|
|
||||||
|
# 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 files WHERE filename = ?", file
|
||||||
|
LOGGER.debug "File '#{fileinfo[:filename]}' was deleted"
|
||||||
|
successfull_files << file
|
||||||
|
rescue ex : DB::NoResultsError
|
||||||
|
LOGGER.error("File '#{file}' doesn't exist or is not registered in the database: #{ex.message}")
|
||||||
|
failed_files << file
|
||||||
|
rescue ex
|
||||||
|
LOGGER.error "Unknown error: #{ex.message}"
|
||||||
|
http_error 500, "Unknown error: #{ex.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json = JSON.build do |j|
|
||||||
|
j.object do
|
||||||
|
j.field "successfull", successfull_files.size
|
||||||
|
j.field "failed", failed_files.size
|
||||||
|
j.field "successfullFiles", successfull_files
|
||||||
|
j.field "failedFiles", failed_files
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# /api/admin/deleteiplimit
|
||||||
|
# curl -X POST -H "Content-Type: application/json" -H "X-Api-Key: asd" http://localhost:8080/api/admin/deleteiplimit -d '{"ips": ["127.0.0.1"]}' | jq
|
||||||
|
|
||||||
|
def delete_ip_limit(env)
|
||||||
|
data = env.params.json["ips"].as((Array(JSON::Any)))
|
||||||
|
successfull = [] of String
|
||||||
|
failed = [] of String
|
||||||
|
data.each do |item|
|
||||||
|
item = item.to_s
|
||||||
|
begin
|
||||||
|
# Delete entry from db
|
||||||
|
SQL.exec "DELETE FROM ips WHERE ip = ?", item
|
||||||
|
LOGGER.debug "Rate limit for '#{item}' was deleted"
|
||||||
|
successfull << item
|
||||||
|
rescue ex : DB::NoResultsError
|
||||||
|
LOGGER.error("Rate limit for '#{item}' doesn't exist or is not registered in the database: #{ex.message}")
|
||||||
|
failed << item
|
||||||
|
rescue ex
|
||||||
|
LOGGER.error "Unknown error: #{ex.message}"
|
||||||
|
Macros.ee 500, "Unknown error: #{ex.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json = JSON.build do |j|
|
||||||
|
j.object do
|
||||||
|
j.field "successfull", successfull.size
|
||||||
|
j.field "failed", failed.size
|
||||||
|
j.field "successfullUnbans", successfull
|
||||||
|
j.field "failedUnbans", failed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# /api/admin/fileinfo
|
||||||
|
# curl -X POST -H "Content-Type: application/json" -H "X-Api-Key: asd" http://localhost:8080/api/admin/fileinfo -d '{"files": ["j63"]}' | jq
|
||||||
|
def retrieve_file_info(env)
|
||||||
|
data = env.params.json["files"].as((Array(JSON::Any)))
|
||||||
|
successfull = [] of NamedTuple(original_filename: String, filename: String, extension: String,
|
||||||
|
uploaded_at: String, checksum: String, ip: String, delete_key: String,
|
||||||
|
thumbnail: String | Nil)
|
||||||
|
failed = [] of String
|
||||||
|
data.each do |item|
|
||||||
|
item = item.to_s
|
||||||
|
begin
|
||||||
|
fileinfo = SQL.query_one("SELECT original_filename, filename, extension,
|
||||||
|
uploaded_at, checksum, ip, delete_key, thumbnail
|
||||||
|
FROM files
|
||||||
|
WHERE filename = ?",
|
||||||
|
item,
|
||||||
|
as: {original_filename: String, filename: String, extension: String,
|
||||||
|
uploaded_at: String, checksum: String, ip: String, delete_key: String,
|
||||||
|
thumbnail: String | Nil})
|
||||||
|
successfull << fileinfo
|
||||||
|
rescue ex : DB::NoResultsError
|
||||||
|
LOGGER.error("File '#{item}' is not registered in the database: #{ex.message}")
|
||||||
|
failed << item
|
||||||
|
rescue ex
|
||||||
|
LOGGER.error "Unknown error: #{ex.message}"
|
||||||
|
Macros.ee 500, "Unknown error: #{ex.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json = JSON.build do |j|
|
||||||
|
j.object do
|
||||||
|
j.field "files" do
|
||||||
|
j.array do
|
||||||
|
successfull.each do |fileinfo|
|
||||||
|
j.object do
|
||||||
|
j.field fileinfo[:filename] do
|
||||||
|
j.object do
|
||||||
|
j.field "original_filename", fileinfo[:original_filename]
|
||||||
|
j.field "filename", fileinfo[:filename]
|
||||||
|
j.field "extension", fileinfo[:extension]
|
||||||
|
j.field "uploaded_at", fileinfo[:uploaded_at]
|
||||||
|
j.field "checksum", fileinfo[:checksum]
|
||||||
|
j.field "ip", fileinfo[:ip]
|
||||||
|
j.field "delete_key", fileinfo[:delete_key]
|
||||||
|
j.field "thumbnail", fileinfo[:thumbnail]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
j.field "successfull", successfull.size
|
||||||
|
j.field "failed", failed.size
|
||||||
|
# j.field "successfullFiles"
|
||||||
|
j.field "failedFiles", failed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# /api/admin/torexitnodes
|
||||||
|
# curl -X GET -H "X-Api-Key: asd" http://localhost:8080/api/admin/torexitnodes | jq
|
||||||
|
def retrieve_tor_exit_nodes(env, nodes)
|
||||||
|
json = JSON.build do |j|
|
||||||
|
j.object do
|
||||||
|
j.field "ips", nodes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# /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
|
||||||
|
|
||||||
|
# /api/admin/blacklist
|
||||||
|
# curl -X GET -H "X-Api-Key: asd" http://localhost:8080/api/admin/torexitnodes | jq
|
||||||
|
def add_ip_to_blacklist(env, nodes)
|
||||||
|
json = JSON.build do |j|
|
||||||
|
j.object do
|
||||||
|
j.field "ips", nodes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# MODULE END
|
||||||
|
end
|
39
src/routes/delete.cr
Normal file
39
src/routes/delete.cr
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
module Routes::Deletion
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def delete_file(env)
|
||||||
|
key = env.params.query["key"]?
|
||||||
|
|
||||||
|
if !key || key.empty?
|
||||||
|
ee 400, "No delete key supplied"
|
||||||
|
end
|
||||||
|
|
||||||
|
file = Database::Files.select_with_key(key)
|
||||||
|
|
||||||
|
if file
|
||||||
|
full_filename = file.filename + file.extension
|
||||||
|
thumbnail = file.thumbnail
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Delete file
|
||||||
|
File.delete("#{CONFIG.files}/#{full_filename}")
|
||||||
|
|
||||||
|
if file.thumbnail
|
||||||
|
File.delete("#{CONFIG.thumbnails}/#{thumbnail}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete entry from db
|
||||||
|
Database::Files.delete_with_key(key)
|
||||||
|
|
||||||
|
LOGGER.debug "File '#{full_filename}' was deleted using key '#{key}'}"
|
||||||
|
msg("File '#{full_filename}' deleted successfully")
|
||||||
|
rescue ex
|
||||||
|
LOGGER.error("Unknown error: #{ex.message}")
|
||||||
|
ee 500, "Unknown error"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
LOGGER.debug "Key '#{env.params.query["key"]}' does not exist"
|
||||||
|
ee 401, "Delete key '#{env.params.query["key"]}' does not exist. No files were deleted"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
66
src/routes/misc.cr
Normal file
66
src/routes/misc.cr
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
require "http/client"
|
||||||
|
|
||||||
|
module Routing::Misc
|
||||||
|
extend self
|
||||||
|
|
||||||
|
struct Stats
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
@[JSON::Field(key: "filesHosted")]
|
||||||
|
property files_hosted : Int32
|
||||||
|
@[JSON::Field(key: "maxUploadSize")]
|
||||||
|
property max_upload_size : String
|
||||||
|
@[JSON::Field(key: "thumbnailGeneration")]
|
||||||
|
property thumbnail_generation : Bool
|
||||||
|
@[JSON::Field(key: "filenameLength")]
|
||||||
|
property filename_length : Int32
|
||||||
|
@[JSON::Field(key: "alternativeDomains")]
|
||||||
|
property alternative_domains : Array(String)
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@files_hosted = Database::Files.file_count
|
||||||
|
@max_upload_size = CONFIG.size_limit.to_s
|
||||||
|
@thumbnail_generation = CONFIG.generate_thumbnails
|
||||||
|
@filename_length = CONFIG.filename_length
|
||||||
|
@alternative_domains = CONFIG.alternative_domains
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def stats(env)
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
Stats.new.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def sharex_config(env)
|
||||||
|
host = env.request.headers["X-Forwarded-Host"]? || env.request.headers["Host"]?
|
||||||
|
scheme = env.request.headers["X-Forwarded-Proto"]? || "http"
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
# So it's able to download the file instead of displaying it
|
||||||
|
env.response.headers["Content-Disposition"] = "attachment; filename=\"#{host}.sxcu\""
|
||||||
|
|
||||||
|
return %({
|
||||||
|
"Version": "14.0.1",
|
||||||
|
"DestinationType": "ImageUploader, FileUploader",
|
||||||
|
"RequestMethod": "POST",
|
||||||
|
"RequestURL": "#{scheme}://#{host}/upload",
|
||||||
|
"Body": "MultipartFormData",
|
||||||
|
"FileFormName": "file",
|
||||||
|
"URL": "{json:link}",
|
||||||
|
"DeletionURL": "{json:deleteLink}",
|
||||||
|
"ErrorMessage": "{json:error}"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def chatterino_config(env)
|
||||||
|
host = env.request.headers["X-Forwarded-Host"]? || env.request.headers["Host"]?
|
||||||
|
scheme = env.request.headers["X-Forwarded-Proto"]? || "http"
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
return %({
|
||||||
|
"requestUrl": "#{scheme}://#{host}/upload",
|
||||||
|
formField": "data",
|
||||||
|
imageLink": "{link}",
|
||||||
|
deleteLink": "{deleteLink}"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
51
src/routes/retrieve.cr
Normal file
51
src/routes/retrieve.cr
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
module Routes::Retrieve
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def retrieve_file(env)
|
||||||
|
host = env.request.headers["X-Forwarded-Host"]? || env.request.headers["Host"]?
|
||||||
|
scheme = env.request.headers["X-Forwarded-Proto"]? || "http"
|
||||||
|
ip_addr = env.request.headers["X-Real-IP"]? || env.request.remote_address.as?(Socket::IPAddress).try &.address
|
||||||
|
filename = env.params.url["filename"].split(".").first
|
||||||
|
|
||||||
|
begin
|
||||||
|
file = Database::Files.select(filename)
|
||||||
|
if file.nil?
|
||||||
|
ee 404, "File '#{filename}' does not exist"
|
||||||
|
end
|
||||||
|
rescue ex
|
||||||
|
LOGGER.debug "Error when retrieving file '#{filename}': #{ex.message}"
|
||||||
|
ee 500, "Error when retrieving file '#{filename}'"
|
||||||
|
end
|
||||||
|
|
||||||
|
env.response.headers["Content-Disposition"] = "inline; filename*=UTF-8''#{file.original_filename}"
|
||||||
|
env.response.headers["ETag"] = "#{file.checksum}"
|
||||||
|
|
||||||
|
CONFIG.opengraph_useragents.each do |useragent|
|
||||||
|
env.response.content_type = "text/html"
|
||||||
|
|
||||||
|
if env.request.headers["User-Agent"]?.try &.includes?(useragent)
|
||||||
|
return %(<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta property="og:title" content="#{file.original_filename}">
|
||||||
|
<meta property="og:url" content="#{scheme}://#{host}/#{file.filename}">
|
||||||
|
#{%(<meta property="og:image" content="#{scheme}://#{host}/thumbnail/#{file.filename}.jpg">) if file.thumbnail}
|
||||||
|
</head>
|
||||||
|
</html>)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
send_file env, "#{CONFIG.files}/#{file.filename}#{file.extension}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def retrieve_thumbnail(env)
|
||||||
|
thumbnail = env.params.url["thumbnail"]?
|
||||||
|
|
||||||
|
begin
|
||||||
|
send_file env, "#{CONFIG.thumbnails}/#{thumbnail}"
|
||||||
|
rescue ex
|
||||||
|
LOGGER.debug "Thumbnail '#{thumbnail}' does not exist: #{ex.message}"
|
||||||
|
ee 403, "Thumbnail '#{thumbnail}' does not exist"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
112
src/routes/upload.cr
Normal file
112
src/routes/upload.cr
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
module Routes::Upload
|
||||||
|
extend self
|
||||||
|
|
||||||
|
struct Response
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
property link : String
|
||||||
|
@[JSON::Field(key: "linkExt")]
|
||||||
|
property link_ext : String
|
||||||
|
property id : String
|
||||||
|
property ext : String
|
||||||
|
property name : String
|
||||||
|
property checksum : String?
|
||||||
|
@[JSON::Field(key: "deleteKey")]
|
||||||
|
property delete_key : String
|
||||||
|
@[JSON::Field(key: "deleteLink")]
|
||||||
|
property delete_link : String
|
||||||
|
|
||||||
|
def initialize(file : UFile, scheme : String, host : String?)
|
||||||
|
@link = "#{scheme}://#{host}/#{file.filename}"
|
||||||
|
@link_ext = "#{scheme}://#{host}/#{file.filename}#{file.extension}"
|
||||||
|
@id = file.filename
|
||||||
|
@ext = file.extension
|
||||||
|
@name = file.original_filename
|
||||||
|
@checksum = file.checksum
|
||||||
|
@delete_key = file.delete_key
|
||||||
|
@delete_link = "#{scheme}://#{host}/delete?key=#{file.delete_key}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def upload(env)
|
||||||
|
host = env.request.headers["X-Forwarded-Host"]? || env.request.headers["Host"]?
|
||||||
|
scheme = env.request.headers["X-Forwarded-Proto"]? || "http"
|
||||||
|
ip_addr = env.request.headers["X-Real-IP"]? || env.request.remote_address.as?(Socket::IPAddress).try &.address
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
# 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"]?.try &.to_i == nil
|
||||||
|
if env.request.headers["Content-Length"].to_i > 1048576*CONFIG.size_limit
|
||||||
|
ee 413, "File is too big. The maximum size allowed is #{CONFIG.size_limit}MiB"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
file = UFile.new
|
||||||
|
ip = UIP.new
|
||||||
|
|
||||||
|
HTTP::FormData.parse(env.request) do |upload|
|
||||||
|
upload_filename = upload.filename
|
||||||
|
|
||||||
|
if upload_filename
|
||||||
|
file.original_filename = upload_filename
|
||||||
|
else
|
||||||
|
LOGGER.debug "No file provided by the user"
|
||||||
|
ee 403, "No file provided"
|
||||||
|
end
|
||||||
|
|
||||||
|
file.filename = Utils.generate_filename
|
||||||
|
|
||||||
|
if file.original_filename == "control_v.png"
|
||||||
|
file.original_filename = file.filename
|
||||||
|
end
|
||||||
|
|
||||||
|
file.extension = File.extname("#{upload.filename}")
|
||||||
|
full_filename = file.filename + file.extension
|
||||||
|
file_path = "#{CONFIG.files}/#{full_filename}"
|
||||||
|
|
||||||
|
if CONFIG.blocked_extensions.includes?(file.extension.split(".")[1])
|
||||||
|
ee 401, "Extension '#{file.extension}' is not allowed"
|
||||||
|
end
|
||||||
|
|
||||||
|
File.open(file_path, "w") do |output|
|
||||||
|
IO.copy(upload.body, output)
|
||||||
|
end
|
||||||
|
|
||||||
|
file.uploaded_at = Time.utc.to_unix
|
||||||
|
|
||||||
|
if CONFIG.enable_checksums
|
||||||
|
file.checksum = Utils::Hashing.hash_file(file_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
file.ip = ip_addr.to_s
|
||||||
|
ip.ip = file.ip
|
||||||
|
ip.date = file.uploaded_at
|
||||||
|
|
||||||
|
if CONFIG.delete_key_length > 0
|
||||||
|
file.delete_key = Random.base58(CONFIG.delete_key_length)
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
spawn { Utils.generate_thumbnail(file.filename, file.extension) }
|
||||||
|
rescue ex
|
||||||
|
LOGGER.error "An error ocurred when trying to generate a thumbnail: #{ex.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
Database::Files.insert(file)
|
||||||
|
exists = Database::IP.insert(ip).rows_affected == 0
|
||||||
|
Database::IP.increase_count(ip) if exists
|
||||||
|
rescue ex
|
||||||
|
LOGGER.error "An error ocurred when trying to insert the data into the DB: #{ex.message}"
|
||||||
|
ee 500, "An error ocurred when trying to insert the data into the DB"
|
||||||
|
end
|
||||||
|
|
||||||
|
res = Response.new(file, scheme, host)
|
||||||
|
res.to_json
|
||||||
|
end
|
||||||
|
end
|
18
src/routes/views.cr
Normal file
18
src/routes/views.cr
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
module Routes::Views
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def root(env)
|
||||||
|
host = env.request.headers["X-Forwarded-Host"]? || env.request.headers["Host"]?
|
||||||
|
scheme = env.request.headers["X-Forwarded-Proto"]? || "http"
|
||||||
|
files_hosted = Database::Files.file_count
|
||||||
|
|
||||||
|
render "src/views/index.ecr"
|
||||||
|
end
|
||||||
|
|
||||||
|
def chatterino(env)
|
||||||
|
host = env.request.headers["X-Forwarded-Host"]? || env.request.headers["Host"]?
|
||||||
|
scheme = env.request.headers["X-Forwarded-Proto"]? || "http"
|
||||||
|
|
||||||
|
render "src/views/chatterino.ecr"
|
||||||
|
end
|
||||||
|
end
|
156
src/routing.cr
156
src/routing.cr
|
@ -1,78 +1,114 @@
|
||||||
require "./http-errors"
|
require "./macros"
|
||||||
|
require "./routes/**"
|
||||||
|
|
||||||
module Routing
|
module Routing
|
||||||
extend self
|
extend self
|
||||||
@@exit_nodes = Array(String).new
|
|
||||||
# @@ip_address : String = ""
|
{% for http_method in {"get", "post", "delete", "options", "patch", "put"} %}
|
||||||
# @@protocol : String = ""
|
|
||||||
# @@host : String = ""
|
macro {{http_method.id}}(path, controller, method = :handle)
|
||||||
if CONFIG.blockTorAddresses
|
unless Kemal::Utils.path_starts_with_slash?(\{{path}})
|
||||||
spawn do
|
raise Kemal::Exceptions::InvalidPathStartException.new({{http_method}}, \{{path}})
|
||||||
# Wait a little for Utils.retrieve_tor_exit_nodes to execute first
|
end
|
||||||
# or it will load an old exit node list
|
|
||||||
# I think this can be replaced by channels which makes me able to
|
Kemal::RouteHandler::INSTANCE.add_route({{http_method.upcase}}, \{{path}}) do |env|
|
||||||
# receive data from fibers
|
\{{ controller }}.\{{ method.id }}(env)
|
||||||
sleep 5
|
|
||||||
loop do
|
|
||||||
LOGGER.debug "Updating Tor exit nodes array"
|
|
||||||
@@exit_nodes = Utils.load_tor_exit_nodes
|
|
||||||
sleep CONFIG.torExitNodesCheck + 5
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
before_post do |env|
|
|
||||||
if @@exit_nodes.includes?(Utils.ip_address(env))
|
{% end %}
|
||||||
halt env, status_code: 401, response: error401(CONFIG.torMessage)
|
|
||||||
|
# before_post "/api/admin/*" do |env|
|
||||||
|
# env.response.content_type = "application/json"
|
||||||
|
|
||||||
|
# if env.request.headers.try &.["X-Api-Key"]? != CONFIG.admin_api_key || nil
|
||||||
|
# halt env, status_code: 401, response: "Wrong API Key"
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
|
||||||
|
before_post do |env|
|
||||||
|
tor_exit_nodes = Utils::Tor.exit_nodes
|
||||||
|
api_key = env.request.headers["X-Api-Key"]?
|
||||||
|
|
||||||
|
# Skips Tor blocking and Rate limits if the API key matches
|
||||||
|
if api_key == CONFIG.admin_api_key
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
if CONFIG.block_tor_addresses && tor_exit_nodes.includes?(Headers.ip_addr)
|
||||||
|
halt env, status_code: 401, response: CONFIG.tor_message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before_post "/upload" do |env|
|
||||||
|
ip = Headers.ip_addr
|
||||||
|
if !ip
|
||||||
|
halt env, status_code: 401, response: "X-Real-IP header not present. Contact the admin to fix this!"
|
||||||
|
end
|
||||||
|
|
||||||
|
ip_info = Database::IP.select(ip)
|
||||||
|
|
||||||
|
if ip_info.nil?
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
if CONFIG.files_per_ip > 0
|
||||||
|
time_since_first_upload = Time.utc.to_unix - ip_info.date
|
||||||
|
time_until_unban = ip_info.date - Time.utc.to_unix + CONFIG.rate_limit_period
|
||||||
|
|
||||||
|
if time_since_first_upload > CONFIG.rate_limit_period
|
||||||
|
Database::IP.delete(ip_info.ip)
|
||||||
end
|
end
|
||||||
ip_count = SQL.query_one "SELECT count FROM #{CONFIG.ipTableName} WHERE ip = ?", Utils.ip_address(env), as: Int32
|
|
||||||
if ip_count >= CONFIG.filesPerIP
|
if ip_info.count >= CONFIG.files_per_ip && time_since_first_upload < CONFIG.rate_limit_period
|
||||||
halt env, status_code: 401, response: error401("Rate limited!")
|
halt env, status_code: 401, response: "Rate limited! Try again in #{time_until_unban} seconds"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def register_all
|
def register_all
|
||||||
get "/" do |env|
|
get "/", Routes::Views, :root
|
||||||
host = Utils.host(env)
|
get "/info/chatterino", Routes::Views, :chatterino
|
||||||
files_hosted = SQL.query_one "SELECT COUNT (filename) FROM #{CONFIG.dbTableName}", as: Int32
|
|
||||||
render "src/views/index.ecr"
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/upload" do |env|
|
post "/upload", Routes::Upload, :upload
|
||||||
Handling.upload(env)
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/api/uploadurl" do |env|
|
get "/:filename", Routes::Retrieve, :retrieve_file
|
||||||
Handling.upload_url(env)
|
get "/thumbnail/:thumbnail", Routes::Retrieve, :retrieve_thumbnail
|
||||||
end
|
|
||||||
|
|
||||||
get "/:filename" do |env|
|
get "/delete", Routes::Deletion, :delete_file
|
||||||
Handling.retrieve_file(env)
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/thumbnail/:thumbnail" do |env|
|
get "/api/stats", Routing::Misc, :stats
|
||||||
Handling.retrieve_thumbnail(env)
|
get "/info/sharex.sxcu", Routing::Misc, :sharex_config
|
||||||
end
|
get "/info/chatterinoconfig", Routing::Misc, :chatterino_config
|
||||||
|
|
||||||
get "/delete" do |env|
|
# if CONFIG.admin_enabled
|
||||||
Handling.delete_file(env)
|
# self.register_admin
|
||||||
end
|
# end
|
||||||
|
|
||||||
get "/api/stats" do |env|
|
|
||||||
Handling.stats(env)
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/sharex.sxcu" do |env|
|
|
||||||
Handling.sharex_config(env)
|
|
||||||
end
|
|
||||||
|
|
||||||
self.register_admin
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def register_admin
|
# def register_admin
|
||||||
if CONFIG.adminEnabled
|
# # post "/api/admin/upload" do |env|
|
||||||
post "/api/admin/delete" do |env|
|
# # Routes::Admin.delete_ip_limit(env)
|
||||||
Handling::Admin.delete_file(env)
|
# # end
|
||||||
end
|
# post "/api/admin/delete" do |env|
|
||||||
end
|
# Routes::Admin.delete_file(env)
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
|
||||||
|
# post "/api/admin/deleteiplimit" do |env|
|
||||||
|
# Routes::Admin.delete_ip_limit(env)
|
||||||
|
# end
|
||||||
|
|
||||||
|
# post "/api/admin/fileinfo" do |env|
|
||||||
|
# Routes::Admin.retrieve_file_info(env)
|
||||||
|
# end
|
||||||
|
|
||||||
|
# get "/api/admin/torexitnodes" do |env|
|
||||||
|
# Routes::Admin.retrieve_tor_exit_nodes(env, @@exit_nodes)
|
||||||
|
# end
|
||||||
|
|
||||||
|
error 404 do |env|
|
||||||
|
env.response.content_type = "text/plain"
|
||||||
|
"File not found.\nArchivo no encontrado."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
24
src/types/ip.cr
Normal file
24
src/types/ip.cr
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
struct UIP
|
||||||
|
# Without this, this class will not be able to be used as `as: IP` on
|
||||||
|
# SQL queries
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property ip : String
|
||||||
|
property count : Int32
|
||||||
|
property date : Int64
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
@ip = "",
|
||||||
|
@count = 1,
|
||||||
|
@date = 0,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_tuple
|
||||||
|
{% begin %}
|
||||||
|
{
|
||||||
|
{{@type.instance_vars.map(&.name).splat}}
|
||||||
|
}
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
end
|
34
src/types/ufile.cr
Normal file
34
src/types/ufile.cr
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
struct UFile
|
||||||
|
# Without this, this class will not be able to be used as `as: UFile` on
|
||||||
|
# SQL queries
|
||||||
|
include DB::Serializable
|
||||||
|
|
||||||
|
property original_filename : String
|
||||||
|
property filename : String
|
||||||
|
property extension : String
|
||||||
|
property uploaded_at : Int64
|
||||||
|
property checksum : String?
|
||||||
|
property ip : String
|
||||||
|
property delete_key : String
|
||||||
|
property thumbnail : String?
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
@original_filename = "",
|
||||||
|
@filename = "",
|
||||||
|
@extension = "",
|
||||||
|
@uploaded_at = 0,
|
||||||
|
@checksum = nil,
|
||||||
|
@ip = "",
|
||||||
|
@delete_key = "",
|
||||||
|
@thumbnail = nil,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_tuple
|
||||||
|
{% begin %}
|
||||||
|
{
|
||||||
|
{{@type.instance_vars.map(&.name).splat}}
|
||||||
|
}
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
end
|
236
src/utils.cr
236
src/utils.cr
|
@ -1,236 +0,0 @@
|
||||||
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
|
|
||||||
LOGGER.info "Creating sqlite3 database at '#{CONFIG.db}'"
|
|
||||||
begin
|
|
||||||
SQL.exec "CREATE TABLE IF NOT EXISTS #{CONFIG.dbTableName}
|
|
||||||
(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}
|
|
||||||
(ip text UNIQUE, count integer DEFAULT 0)"
|
|
||||||
rescue ex
|
|
||||||
LOGGER.fatal "#{ex.message}"
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_files_dir
|
|
||||||
if !Dir.exists?("#{CONFIG.files}")
|
|
||||||
LOGGER.info "Creating files folder under '#{CONFIG.files}'"
|
|
||||||
begin
|
|
||||||
Dir.mkdir("#{CONFIG.files}")
|
|
||||||
rescue ex
|
|
||||||
LOGGER.fatal "#{ex.message}"
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_thumbnails_dir
|
|
||||||
if !CONFIG.thumbnails
|
|
||||||
if !Dir.exists?("#{CONFIG.thumbnails}")
|
|
||||||
LOGGER.info "Creating thumbnails folder under '#{CONFIG.thumbnails}'"
|
|
||||||
begin
|
|
||||||
Dir.mkdir("#{CONFIG.thumbnails}")
|
|
||||||
rescue ex
|
|
||||||
LOGGER.fatal "#{ex.message}"
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
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}"
|
|
||||||
end
|
|
||||||
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
|
|
||||||
dependencies = ["ffmpeg"]
|
|
||||||
dependencies.each do |dep|
|
|
||||||
next if !CONFIG.generateThumbnails
|
|
||||||
if !Process.find_executable(dep)
|
|
||||||
LOGGER.fatal("'#{dep}' was not found")
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# TODO:
|
|
||||||
# def check_duplicate(upload)
|
|
||||||
# file_checksum = SQL.query_all("SELECT checksum FROM #{CONFIG.dbTableName} WHERE original_filename = ?", upload.filename, as:String).try &.[0]?
|
|
||||||
# if file_checksum.nil?
|
|
||||||
# return
|
|
||||||
# else
|
|
||||||
# uploaded_file_checksum = hash_io(upload.body)
|
|
||||||
# pp file_checksum
|
|
||||||
# pp uploaded_file_checksum
|
|
||||||
# if file_checksum == uploaded_file_checksum
|
|
||||||
# puts "Dupl"
|
|
||||||
# end
|
|
||||||
# end
|
|
||||||
# end
|
|
||||||
|
|
||||||
def hash_file(file_path : String) : String
|
|
||||||
Digest::SHA1.hexdigest &.file(file_path)
|
|
||||||
end
|
|
||||||
|
|
||||||
def hash_io(file_path : IO) : String
|
|
||||||
Digest::SHA1.hexdigest &.update(file_path)
|
|
||||||
end
|
|
||||||
|
|
||||||
# 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
|
|
||||||
return filename
|
|
||||||
else
|
|
||||||
LOGGER.debug "Filename collision! Generating a new filename"
|
|
||||||
filename = Random.base58(CONFIG.fileameLength)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_thumbnail(filename, extension)
|
|
||||||
# Disable generation if false
|
|
||||||
return if !CONFIG.generateThumbnails
|
|
||||||
LOGGER.debug "Generating thumbnail for #{filename + extension} in background"
|
|
||||||
process = Process.run("ffmpeg",
|
|
||||||
[
|
|
||||||
"-hide_banner",
|
|
||||||
"-i",
|
|
||||||
"#{CONFIG.files}/#{filename + extension}",
|
|
||||||
"-movflags", "faststart",
|
|
||||||
"-f", "mjpeg",
|
|
||||||
"-q:v", "2",
|
|
||||||
"-vf", "scale='min(350,iw)':'min(350,ih)':force_original_aspect_ratio=decrease, thumbnail=100",
|
|
||||||
"-frames:v", "1",
|
|
||||||
"-update", "1",
|
|
||||||
"#{CONFIG.thumbnails}/#{filename}.jpg",
|
|
||||||
])
|
|
||||||
if process.normal_exit?
|
|
||||||
LOGGER.debug "Thumbnail for #{filename + extension} generated successfully"
|
|
||||||
SQL.exec "UPDATE #{CONFIG.dbTableName} SET thumbnail = ? WHERE filename = ?", filename + ".jpg", filename
|
|
||||||
else
|
|
||||||
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
|
|
||||||
|
|
||||||
def delete_file(env)
|
|
||||||
fileinfo = SQL.query_all("SELECT filename, extension, thumbnail
|
|
||||||
FROM #{CONFIG.dbTableName}
|
|
||||||
WHERE delete_key = ?",
|
|
||||||
env.params.query["key"],
|
|
||||||
as: {filename: String, extension: String, thumbnail: String | Nil})[0]
|
|
||||||
|
|
||||||
# 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"]
|
|
||||||
LOGGER.debug "File '#{fileinfo[:filename]}' was deleted using key '#{env.params.query["key"]}'}"
|
|
||||||
msg("File '#{fileinfo[:filename]}' deleted successfully")
|
|
||||||
end
|
|
||||||
|
|
||||||
def detect_extension(file) : String
|
|
||||||
magic_bytes = {
|
|
||||||
".png" => "89504e470d0a1a0a",
|
|
||||||
".jpg" => "ffd8ff",
|
|
||||||
".webm" => "1a45dfa3",
|
|
||||||
".mp4" => "66747970",
|
|
||||||
".gif" => "474946383",
|
|
||||||
".7z" => "377abcaf271c",
|
|
||||||
".gz" => "1f8b",
|
|
||||||
}
|
|
||||||
file = File.open(file)
|
|
||||||
slice = Bytes.new(8)
|
|
||||||
hex = IO::Hexdump.new(file)
|
|
||||||
hex.read(slice)
|
|
||||||
magic_bytes.each do |ext, mb|
|
|
||||||
if slice.hexstring.includes?(mb)
|
|
||||||
return ext
|
|
||||||
end
|
|
||||||
end
|
|
||||||
""
|
|
||||||
end
|
|
||||||
|
|
||||||
def retrieve_tor_exit_nodes
|
|
||||||
LOGGER.debug "Retrieving Tor exit nodes list"
|
|
||||||
resp = HTTP::Client.get(CONFIG.torExitNodesUrl) do |res|
|
|
||||||
if res.success? && res.status_code == 200
|
|
||||||
begin
|
|
||||||
File.open(CONFIG.torExitNodesFile, "w") do |output|
|
|
||||||
IO.copy(res.body_io, output)
|
|
||||||
end
|
|
||||||
rescue ex
|
|
||||||
LOGGER.error "Failed to write to file: #{ex.message}"
|
|
||||||
end
|
|
||||||
else
|
|
||||||
LOGGER.error "Failed to retrieve exit nodes list. Status Code: #{res.status_code}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_tor_exit_nodes
|
|
||||||
exit_nodes = File.read_lines(CONFIG.torExitNodesFile)
|
|
||||||
end
|
|
||||||
|
|
||||||
def ip_address(env) : String
|
|
||||||
begin
|
|
||||||
return env.request.headers.try &.["X-Forwarded-For"]
|
|
||||||
rescue
|
|
||||||
return env.request.remote_address.to_s.split(":").first
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def protocol(env) : String
|
|
||||||
begin
|
|
||||||
return env.request.headers.try &.["X-Forwarded-Proto"]
|
|
||||||
rescue
|
|
||||||
return "http"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def host(env) : String
|
|
||||||
begin
|
|
||||||
return env.request.headers.try &.["X-Forwarded-Host"]
|
|
||||||
rescue
|
|
||||||
return env.request.headers["Host"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
11
src/utils/hashing.cr
Normal file
11
src/utils/hashing.cr
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
module Utils::Hashing
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def hash_file(file_path : String) : String
|
||||||
|
Digest::SHA1.hexdigest &.file(file_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def hash_io(file : IO) : String
|
||||||
|
Digest::SHA1.hexdigest &.update(file)
|
||||||
|
end
|
||||||
|
end
|
38
src/utils/tor.cr
Normal file
38
src/utils/tor.cr
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
module Utils::Tor
|
||||||
|
extend self
|
||||||
|
@@exit_nodes : Array(String) = [] of String
|
||||||
|
|
||||||
|
def refresh_exit_nodes
|
||||||
|
LOGGER.debug "reload_exit_nodes: Updating Tor exit nodes list"
|
||||||
|
retrieve_tor_exit_nodes
|
||||||
|
LOGGER.debug "reload_exit_nodes: IPs inside the Tor exit nodes list: #{@@exit_nodes.size}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def retrieve_tor_exit_nodes
|
||||||
|
LOGGER.debug "retrieve_tor_exit_nodes: Retrieving Tor exit nodes list"
|
||||||
|
ips = [] of String
|
||||||
|
|
||||||
|
HTTP::Client.get(CONFIG.tor_exit_nodes_url) do |res|
|
||||||
|
begin
|
||||||
|
if res.success? && res.status_code == 200
|
||||||
|
res.body_io.each_line do |line|
|
||||||
|
if line.includes?("ExitAddress")
|
||||||
|
ips << line.split(" ")[1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
@@exit_nodes = ips
|
||||||
|
else
|
||||||
|
LOGGER.error "retrieve_tor_exit_nodes: Failed to retrieve exit nodes list. Status Code: #{res.status_code}"
|
||||||
|
end
|
||||||
|
rescue ex : Socket::ConnectError
|
||||||
|
LOGGER.error "retrieve_tor_exit_nodes: Failed to connect to #{CONFIG.tor_exit_nodes_url}: #{ex.message}"
|
||||||
|
rescue ex
|
||||||
|
LOGGER.error "retrieve_tor_exit_nodes: Unknown error: #{ex.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def exit_nodes : Array(String)
|
||||||
|
return @@exit_nodes
|
||||||
|
end
|
||||||
|
end
|
274
src/utils/utils.cr
Normal file
274
src/utils/utils.cr
Normal file
|
@ -0,0 +1,274 @@
|
||||||
|
module Utils
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def create_db
|
||||||
|
files_table = <<-SQL
|
||||||
|
CREATE TABLE
|
||||||
|
IF NOT EXISTS files
|
||||||
|
(
|
||||||
|
original_filename text not null,
|
||||||
|
filename text not null,
|
||||||
|
extension text not null,
|
||||||
|
uploaded_at integer not null,
|
||||||
|
checksum text,
|
||||||
|
ip text not null,
|
||||||
|
delete_key text not null,
|
||||||
|
thumbnail text,
|
||||||
|
PRIMARY KEY(filename)
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
ip_table = <<-SQL
|
||||||
|
CREATE TABLE
|
||||||
|
IF NOT EXISTS ips
|
||||||
|
(
|
||||||
|
ip text,
|
||||||
|
count integer DEFAULT 0,
|
||||||
|
date integer,
|
||||||
|
PRIMARY KEY(ip)
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
files_table_check = <<-SQL
|
||||||
|
SELECT EXISTS
|
||||||
|
(
|
||||||
|
SELECT 1 FROM
|
||||||
|
sqlite_schema
|
||||||
|
WHERE type='table'
|
||||||
|
AND name='files'
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
ip_table_check = <<-SQL
|
||||||
|
SELECT EXISTS
|
||||||
|
(
|
||||||
|
SELECT 1 FROM
|
||||||
|
sqlite_schema
|
||||||
|
WHERE type='table'
|
||||||
|
AND name='ips'
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
files_table_exists = SQL.query_one(files_table_check, as: Bool)
|
||||||
|
ip_table_exists = SQL.query_one(ip_table_check, as: Bool)
|
||||||
|
|
||||||
|
if (!files_table_exists)
|
||||||
|
LOGGER.info "create_db: Creating table 'files'"
|
||||||
|
begin
|
||||||
|
SQL.exec(files_table)
|
||||||
|
rescue ex
|
||||||
|
LOGGER.fatal "#{ex.message}"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if (!ip_table_exists)
|
||||||
|
LOGGER.info "create_db: Creating table 'ips'"
|
||||||
|
begin
|
||||||
|
SQL.exec(ip_table)
|
||||||
|
rescue ex
|
||||||
|
LOGGER.fatal "#{ex.message}"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_files_dir
|
||||||
|
if !Dir.exists?("#{CONFIG.files}")
|
||||||
|
LOGGER.info "Creating files folder under '#{CONFIG.files}'"
|
||||||
|
begin
|
||||||
|
Dir.mkdir("#{CONFIG.files}")
|
||||||
|
rescue ex
|
||||||
|
LOGGER.fatal "#{ex.message}"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_thumbnails_dir
|
||||||
|
if CONFIG.thumbnails
|
||||||
|
if !Dir.exists?("#{CONFIG.thumbnails}")
|
||||||
|
LOGGER.info "Creating thumbnails folder under '#{CONFIG.thumbnails}'"
|
||||||
|
begin
|
||||||
|
Dir.mkdir("#{CONFIG.thumbnails}")
|
||||||
|
rescue ex
|
||||||
|
LOGGER.fatal "#{ex.message}"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_file(env)
|
||||||
|
key = env.params.query["key"]
|
||||||
|
file = SQL.select_with_key(key)
|
||||||
|
full_filename = file.filename + file.extension
|
||||||
|
thumbnail = file.thumbnail
|
||||||
|
|
||||||
|
# Delete file
|
||||||
|
File.delete("#{CONFIG.files}/#{full_filename}")
|
||||||
|
|
||||||
|
if file.thumbnail
|
||||||
|
File.delete("#{CONFIG.thumbnails}/#{thumbnail}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete entry from db
|
||||||
|
Database::Files.delete_with_key(key)
|
||||||
|
|
||||||
|
LOGGER.debug "File '#{full_filename}' was deleted using key '#{key}'}"
|
||||||
|
msg("File '#{full_filename}' deleted successfully")
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO: Spawn a fiber and add each file to an array to bulk delete files from
|
||||||
|
# the database using a single SQL query.
|
||||||
|
# In the end, all old files should be not accessible, even if they are on the
|
||||||
|
# drive.
|
||||||
|
def check_old_files
|
||||||
|
LOGGER.info "check_old_files: Deleting old files"
|
||||||
|
files = Database::Files.old_files
|
||||||
|
|
||||||
|
files.each do |f|
|
||||||
|
full_filename = f.filename + f.extension
|
||||||
|
thumbnail = f.thumbnail
|
||||||
|
|
||||||
|
# TODO: Check if it's able to bypass the path using a filename with a `/` in their name
|
||||||
|
LOGGER.debug "check_old_files: Deleting file '#{full_filename}'"
|
||||||
|
begin
|
||||||
|
File.delete("#{CONFIG.files}/#{full_filename}")
|
||||||
|
|
||||||
|
if thumbnail
|
||||||
|
File.delete("#{CONFIG.thumbnails}/#{thumbnail}")
|
||||||
|
end
|
||||||
|
|
||||||
|
Database::Files.delete(f.filename)
|
||||||
|
rescue File::NotFoundError
|
||||||
|
LOGGER.error "check_old_files: File '#{full_filename}' doesn't seem to exist on the '#{CONFIG.files}', folder, deleting it from the database"
|
||||||
|
Database::Files.delete(f.filename)
|
||||||
|
rescue ex : File::AccessDeniedError
|
||||||
|
LOGGER.error "check_old_files: File '#{full_filename}' failed to be deleted due to bad permissions, deleting it from the database: #{ex.message}"
|
||||||
|
Database::Files.delete(f.filename)
|
||||||
|
rescue ex
|
||||||
|
LOGGER.error "check_old_files: File '#{full_filename}' failed to be deleted, deleting it from the database: #{ex.message}"
|
||||||
|
Database::Files.delete(f.filename)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_dependencies
|
||||||
|
dependencies = ["ffmpeg"]
|
||||||
|
dependencies.each do |dep|
|
||||||
|
next if !CONFIG.generate_thumbnails
|
||||||
|
if !Process.find_executable(dep)
|
||||||
|
LOGGER.fatal("'#{dep}' was not found.")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO: Check if there are no other possibilities to get a random filename and exit
|
||||||
|
def generate_filename
|
||||||
|
filename = Random.base58(CONFIG.filename_length)
|
||||||
|
|
||||||
|
loop do
|
||||||
|
file = Database::Files.select(filename)
|
||||||
|
if !file
|
||||||
|
return filename
|
||||||
|
else
|
||||||
|
LOGGER.trace "Filename collision! Generating a new filename"
|
||||||
|
filename = Random.base58(CONFIG.filename_length)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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? { |ext| extension.downcase.includes?(ext) }
|
||||||
|
|
||||||
|
# Disable generation if false
|
||||||
|
return if !CONFIG.generate_thumbnails || !CONFIG.thumbnails
|
||||||
|
|
||||||
|
LOGGER.debug "Generating thumbnail for #{filename + extension} in background"
|
||||||
|
|
||||||
|
process = Process.run("ffmpeg",
|
||||||
|
[
|
||||||
|
"-hide_banner",
|
||||||
|
"-i",
|
||||||
|
"#{CONFIG.files}/#{filename + extension}",
|
||||||
|
"-movflags", "faststart",
|
||||||
|
"-f", "mjpeg",
|
||||||
|
"-q:v", "2",
|
||||||
|
"-vf", "scale='min(350,iw)':'min(350,ih)':force_original_aspect_ratio=decrease, thumbnail=100",
|
||||||
|
"-frames:v", "1",
|
||||||
|
"-update", "1",
|
||||||
|
"#{CONFIG.thumbnails}/#{filename}.jpg",
|
||||||
|
])
|
||||||
|
|
||||||
|
if process.normal_exit?
|
||||||
|
LOGGER.debug "Thumbnail for '#{filename + extension}' generated successfully"
|
||||||
|
SQL.exec "UPDATE files SET thumbnail = ? WHERE filename = ?", filename + ".jpg", filename
|
||||||
|
else
|
||||||
|
LOGGER.debug "Failed to generate thumbnail for '#{filename + extension}'. Exit code of ffmpeg: #{process.exit_code}"
|
||||||
|
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
|
||||||
|
|
||||||
|
MAGIC_BYTES = {
|
||||||
|
# Images
|
||||||
|
".png" => "89504e470d0a1a0a",
|
||||||
|
".heic" => "6674797068656963",
|
||||||
|
".jpg" => "ffd8ff",
|
||||||
|
".gif" => "474946383",
|
||||||
|
# Videos
|
||||||
|
".mp4" => "66747970",
|
||||||
|
".webm" => "1a45dfa3",
|
||||||
|
".mov" => "6d6f6f76",
|
||||||
|
".wmv" => "3026b2758e66cf11",
|
||||||
|
".flv" => "464c5601",
|
||||||
|
".mpeg" => "000001bx",
|
||||||
|
# Audio
|
||||||
|
".mp3" => "494433",
|
||||||
|
".aac" => "fff1",
|
||||||
|
".wav" => "57415645666d7420",
|
||||||
|
".flac" => "664c614300000022",
|
||||||
|
".ogg" => "4f67675300020000000000000000",
|
||||||
|
".wma" => "3026b2758e66cf11a6d900aa0062ce6c",
|
||||||
|
".aiff" => "464f524d00",
|
||||||
|
# Whatever
|
||||||
|
".7z" => "377abcaf271c",
|
||||||
|
".gz" => "1f8b",
|
||||||
|
".iso" => "4344303031",
|
||||||
|
# Documents
|
||||||
|
"pdf" => "25504446",
|
||||||
|
"html" => "<!DOCTYPE html>",
|
||||||
|
}
|
||||||
|
|
||||||
|
def detect_extension(file) : String
|
||||||
|
file = File.open(file)
|
||||||
|
slice = Bytes.new(16)
|
||||||
|
hex = IO::Hexdump.new(file)
|
||||||
|
# Reads the first 16 bytes of the file in Heap
|
||||||
|
hex.read(slice)
|
||||||
|
MAGIC_BYTES.each do |ext, mb|
|
||||||
|
if slice.hexstring.includes?(mb)
|
||||||
|
return ext
|
||||||
|
end
|
||||||
|
end
|
||||||
|
""
|
||||||
|
end
|
||||||
|
end
|
21
src/views/chatterino.ecr
Normal file
21
src/views/chatterino.ecr
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<!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.png" type="image/png" />
|
||||||
|
<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"><%= scheme %>://<%= 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>
|
||||||
|
<img src="/chatterino.png">
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -4,14 +4,14 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title> <%= host %> </title>
|
<title> <%= host %> </title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="/styles.css">
|
||||||
<link rel="icon" href="./favicon.gif" type="image/gif" />
|
<link rel="icon" href="./favicon.png" type="image/gif" />
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 style="font-size: 68px; text-align: center; margin: 20px;"><%= host %></h1>
|
<h1 style="font-size: 68px; text-align: center; margin: 20px;"><%= host %></h1>
|
||||||
<p style="text-align: center; font-size: 22px;"><%= CONFIG.siteInfo %></p>
|
<p style="text-align: center; font-size: 22px;"><%= CONFIG.site_info %></p>
|
||||||
<div id="drop-area">
|
<div id="drop-area">
|
||||||
<p style='padding: 0;margin: 0; color: #123718bf;'>Arrastra, Pega o Selecciona archivos.</p>
|
<p style='padding: 0;margin: 0; color: #123718bf;'>Arrastra, Pega o Selecciona archivos.</p>
|
||||||
<input type="file" id="fileElem" accept="*/*" style="display: none;">
|
<input type="file" id="fileElem" accept="*/*" style="display: none;">
|
||||||
|
@ -21,24 +21,22 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="text-align:center;">
|
<div style="text-align:center;">
|
||||||
<p>
|
<p>
|
||||||
<a href='./chatterino.png'>Chatterino Config</a> |
|
<a href='/info/chatterino'>Chatterino Config</a> |
|
||||||
<a href='./sharex.sxcu'>ShareX Config</a> |
|
<a href='/info/sharex.sxcu'>ShareX Config</a> |
|
||||||
<a href='https://codeberg.org/Fijxu/file-uploader-crystal'>
|
<a href='https://codeberg.org/Fijxu/file-uploader-crystal'>
|
||||||
file-uploader-crystal (BETA <%= CURRENT_TAG %> - <%= CURRENT_VERSION %> @ <%= CURRENT_BRANCH %>)
|
file-uploader-crystal (BETA <%= CURRENT_TAG %> - <%= CURRENT_VERSION %> @ <%= CURRENT_BRANCH %>)
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p>Archivos alojados: <%= files_hosted %></p>
|
<p>Archivos alojados: <%= files_hosted %></p>
|
||||||
<% if CONFIG.blockTorAddresses %>
|
<% if !CONFIG.alternative_domains.empty? %>
|
||||||
<p style="color: red"><%= CONFIG.torMessage %></p>
|
<p>
|
||||||
<% end %>
|
<% CONFIG.alternative_domains.each do | domain | %>
|
||||||
<% if !CONFIG.alternativeDomains.empty? %>
|
<a href="https://<%= domain %>"><%= domain %></a>
|
||||||
<p>
|
<% end %>
|
||||||
<% CONFIG.alternativeDomains.each do | domain | %>
|
</p>
|
||||||
<a href="https://<%= domain %>"><%= domain %></a>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
Loading…
Add table
Reference in a new issue