├── .env_template ├── .github ├── dependabot.yml └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── __init__.py ├── blueprints │ ├── files.py │ ├── main.py │ └── urls.py └── core │ ├── config.py │ ├── discord.py │ ├── files.py │ ├── urls.py │ └── utils.py ├── requirements.txt └── wsgi.py /.env_template: -------------------------------------------------------------------------------- 1 | # Secret key for the application 2 | FLASK_SECRET = "" 3 | 4 | # Path for uploaded files, uncomment and set value to override default location 5 | # UPLOAD_DIR = "" 6 | 7 | # Allowed extensions, separated by semicolon 8 | ALLOWED_EXTENSIONS = "png;jpg;jpeg;gif;webm;mp4;webp;txt;m4v" 9 | 10 | # Custom extension map, = format, separated by comma 11 | CUSTOM_EXTENSIONS = "video/x-m4v=m4v,image/webp=webp" 12 | 13 | # Password for uploading, optional 14 | UPLOAD_PASSWORD = "" 15 | 16 | # Discord webhooks, separated by semicolon 17 | DISCORD_WEBHOOKS = "" 18 | 19 | # Timeout for discord webhook request 20 | DISCORD_WEBHOOK_TIMEOUT = 5 21 | 22 | # The amount of bytes python-magic will read from uploaded file to determine its extension 23 | MAGIC_BUFFER_BYTES = 2048 24 | 25 | # The amount of bytes for secrets.token_urlsafe, used in shortened URLs 26 | URL_TOKEN_BYTES = 6 27 | 28 | # If the uploaded files should include original filename 29 | USE_ORIGINAL_FILENAME = True -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '31 8 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | uploads/ 3 | .vscode/ 4 | *.db 5 | .env 6 | logs/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 vremes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 2 | [![CodeQL](https://github.com/vremes/shrpy/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/vremes/shrpy/actions/workflows/codeql-analysis.yml) 3 | 4 | [ShareX](https://getsharex.com/) custom uploader/destination server written in Python (Flask). 5 | 6 | I created this mostly for my personal use, but if you have any suggestions, ideas or improvements feel free to open a new issue (pull requests are also welcome). 7 | 8 | # Endpoints 9 | 10 | | Route | HTTP method | Description | 11 | | ----- | ------ | ------ | 12 | `/` | `GET` | Index page with some text just to make sure this application works. | 13 | `/uploads/` | `GET` | Route to serve a given file from uploads directory. | 14 | `/url/` | `GET` | Redirects you to the URL for given short URL token. | 15 | `/api/sharex/upload` | `GET` | ShareX custom uploader configuration for files, you can import this to ShareX from **Destinations** -> **Custom uploader settings** -> **Import** -> **From URL** | 16 | `/api/sharex/shorten` | `GET` | ShareX custom uploader configuration for short URLs, you can import this to ShareX from **Destinations** -> **Custom uploader settings** -> **Import** -> **From URL** | 17 | `/api/upload` | `POST` | Route for file uploads. | 18 | `/api/shorten` | `POST` | Route for URL shortening. | 19 | `/api/delete-short-url//` | `GET` | ShareX deletion URL for short URLs. | 20 | `/api/delete-file//` | `GET` | ShareX deletion URL for files. | 21 | 22 | # Setup 23 | 24 | Below you'll find two examples on how to setup this application. 25 | 26 | ### Development 27 | 1. Clone the repository 28 | ```sh 29 | git clone https://github.com/vremes/shrpy.git 30 | ``` 31 | 2. Move to cloned repository directory and install requirements 32 | ```sh 33 | cd shrpy 34 | pip3 install -r requirements.txt 35 | ``` 36 | 3. Setup `.env` file, see [Configuration](#configuration) for additional information 37 | ```sh 38 | cp .env_template .env 39 | nano .env 40 | ``` 41 | - You **must** set `FLASK_SECRET` to something, good way to generate secrets is the following command 42 | ```sh 43 | python -c "from secrets import token_urlsafe; print(token_urlsafe(64))" 44 | ``` 45 | 4. Run Flask built-in development server 46 | ```sh 47 | python3 wsgi.py 48 | ``` 49 | 50 | ### Production 51 | 1. Install NGINX and Supervisor 52 | ```sh 53 | apt install nginx supervisor 54 | ``` 55 | 2. Install Gunicorn and Gevent 56 | ```sh 57 | pip3 install gunicorn gevent 58 | ``` 59 | 3. Clone the repository to `/var/www/` 60 | ```sh 61 | git clone https://github.com/vremes/shrpy.git /var/www/shrpy 62 | ``` 63 | 4. Move to cloned repository directory and install requirements 64 | ```sh 65 | cd /var/www/shrpy/ 66 | pip3 install -r requirements.txt 67 | ``` 68 | 5. Setup `.env` file, see [Configuration](#configuration) for additional information 69 | ```sh 70 | cp .env_template .env 71 | nano .env 72 | ``` 73 | - You **must** set `FLASK_SECRET` to something, good way to generate secrets is the following command 74 | ```sh 75 | python -c "from secrets import token_urlsafe; print(token_urlsafe(64))" 76 | ``` 77 | 6. Configure Supervisor to run Gunicorn, see [Gunicorn Documentation](https://docs.gunicorn.org/en/stable/index.html) for additional information 78 | ```sh 79 | nano /etc/supervisor/conf.d/shrpy.conf 80 | ``` 81 | - Example configuration: 82 | ``` 83 | [program:shrpy] 84 | directory=/var/www/shrpy 85 | command=gunicorn --bind=127.0.0.1:8000 --worker-class=gevent wsgi:application 86 | autostart=true 87 | autorestart=true 88 | stderr_logfile=/var/log/shrpy.err.log 89 | stdout_logfile=/var/log/shrpy.out.log 90 | ``` 91 | 7. Update Supervisor configuration and configure NGINX 92 | ```sh 93 | supervisorctl update 94 | nano /etc/nginx/sites-available/shrpy.conf 95 | ``` 96 | - Example configuration: 97 | ```nginx 98 | server { 99 | listen 80; 100 | server_name example.com; # <==== Change to your domain name 101 | client_max_body_size 16M; 102 | 103 | location / { 104 | include proxy_params; 105 | proxy_pass http://127.0.0.1:8000; 106 | } 107 | 108 | location /uploads { 109 | alias /var/www/shrpy/app/uploads/; 110 | } 111 | } 112 | ``` 113 | 8. Enable NGINX configuration and restart NGINX 114 | ```sh 115 | ln -s /etc/nginx/sites-available/shrpy.conf /etc/nginx/sites-enabled/ 116 | service nginx restart 117 | ``` 118 | 9. Visit the root (`/`) path on your domain and it should be running: 119 | ```json 120 | { 121 | "message": "It works! Beep boop." 122 | } 123 | ``` 124 | --- 125 | ## Configuration 126 | shrpy looks for config values from OS environment variables. 127 | 128 | You can set these environment variables in [.env_template](https://github.com/vremes/shrpy/blob/master/.env_template) and then rename the `.env_template` to `.env`. 129 | 130 | | Key | Type | Default value | Description | 131 | | ------ | ------ | ------ | ------ | 132 | | `FLASK_SECRET` | `str` | `None` | Secret key for Flask application, see https://flask.palletsprojects.com/en/2.0.x/config/#SECRET_KEY | 133 | | `UPLOAD_DIR` | `str` | `/app/uploads/` | Path for uploaded files. | 134 | | `ALLOWED_EXTENSIONS` | `str` | `png;jpg;jpeg;gif;webm;mp4;webp;txt;m4v` | Allowed file extensions separated by semicolon. | 135 | | `CUSTOM_EXTENSIONS` | `str` | `video/x-m4v=m4v,image/webp=webp` | Additional `mimetype=extension` pairs for Python `mimetypes` module | 136 | | `UPLOAD_PASSWORD` | `str` | `None` | The password to protect `/api/upload` and `/api/shorten` endpoints. | 137 | | `DISCORD_WEBHOOKS` | `str` | `None` | Discord webhook URLs separated by semicolon. | 138 | | `DISCORD_WEBHOOK_TIMEOUT` | `int` | `5` | Timeout for Discord webhook requests in seconds. | 139 | | `MAGIC_BUFFER_BYTES` | `int` | `2048` | The amount of bytes `python-magic` will read from uploaded file to determine its extension. | 140 | | `URL_TOKEN_BYTES` | `int` | `6` | The amount of bytes `secrets.token_urlsafe` will use to generate shortened URLs. | 141 | | `USE_ORIGINAL_FILENAME` | `bool` | `True` | If saved files should include original filename. 142 | 143 | ## HTTP Headers 144 | 145 | | Name | Example value | Description | 146 | | ------ | ------ | ------ | 147 | `Authorization` | `hunter2` | The plaintext password for file uploads and URL shortening, simply ignore this header if you don't use a password. | 148 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from werkzeug.exceptions import HTTPException 3 | 4 | from app.core.utils import ( 5 | create_stdout_logger, 6 | http_error_handler, 7 | setup_db, 8 | get_config 9 | ) 10 | from app.core.files import add_unsupported_mimetypes 11 | 12 | db = setup_db() 13 | logger = create_stdout_logger() 14 | config = get_config() 15 | 16 | def create_app() -> Flask: 17 | """Flask application factory.""" 18 | app = Flask(__name__) 19 | 20 | # Add unsupported mimetypes to mimetypes module 21 | add_unsupported_mimetypes(config.mimetypes_custom_extensions) 22 | 23 | # jsonify HTTP errors 24 | @app.errorhandler(HTTPException) 25 | def handle_exception(e): 26 | return http_error_handler(e) 27 | 28 | # Import blueprints 29 | from app.blueprints.main import main 30 | from app.blueprints.files import files 31 | from app.blueprints.urls import urls 32 | 33 | # Register blueprints 34 | app.register_blueprint(main) 35 | app.register_blueprint(files, url_prefix='/api') 36 | app.register_blueprint(urls, url_prefix='/api') 37 | 38 | return app 39 | -------------------------------------------------------------------------------- /app/blueprints/files.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from werkzeug.security import safe_join 4 | from flask import Blueprint, jsonify, url_for, abort, request 5 | 6 | from app import config, logger 7 | from app.core.utils import ( 8 | auth_required, 9 | create_hmac_hash, 10 | safe_str_comparison 11 | ) 12 | from app.core.discord import ( 13 | create_discord_webhooks, 14 | create_uploaded_file_embed, 15 | execute_webhooks_with_embed 16 | ) 17 | from app.core.files import ( 18 | get_file_extension_from_file, 19 | is_file_extension_allowed, 20 | create_directory, 21 | generate_filename, 22 | get_secure_filename, 23 | delete_file, 24 | ) 25 | 26 | files = Blueprint('files', __name__) 27 | 28 | @files.get('/sharex/upload') 29 | def upload_config(): 30 | return jsonify({ 31 | "Name": "{} (File uploader)".format(request.host), 32 | "Version": "1.0.0", 33 | "DestinationType": "ImageUploader, FileUploader", 34 | "RequestMethod": "POST", 35 | "RequestURL": url_for('files.upload', _external=True), 36 | "Body": "MultipartFormData", 37 | "FileFormName": "file", 38 | "URL": "$json:url$", 39 | "DeletionURL": "$json:delete_url$", 40 | "Headers": { 41 | "Authorization": "YOUR-UPLOAD-PASSWORD-HERE", 42 | }, 43 | "ErrorMessage": "$json:status$" 44 | }) 45 | 46 | @files.post('/upload') 47 | @auth_required 48 | def upload(): 49 | f = request.files.get('file') 50 | 51 | if f is None: 52 | abort(HTTPStatus.BAD_REQUEST, 'Invalid file.') 53 | 54 | file_extension = get_file_extension_from_file(f.stream, config.magic_buffer_bytes) 55 | 56 | if is_file_extension_allowed(file_extension, config.allowed_extensions) is False: 57 | abort(HTTPStatus.UNPROCESSABLE_ENTITY, 'Invalid file type.') 58 | 59 | create_directory(config.upload_directory) 60 | 61 | filename = generate_filename() 62 | 63 | if config.use_original_filename: 64 | secure_filename = get_secure_filename(f.filename) 65 | filename = f'{filename}-{secure_filename}' 66 | 67 | save_filename = filename + file_extension 68 | save_path = safe_join(config.upload_directory, save_filename) 69 | 70 | f.save(save_path) 71 | 72 | hmac_hash = create_hmac_hash(save_filename, config.flask_secret) 73 | file_url = url_for('main.uploads', filename=save_filename, _external=True) 74 | deletion_url = url_for('files.delete_file_with_hash', hmac_hash=hmac_hash, filename=save_filename, _external=True) 75 | 76 | logger.info(f'Saved file: {save_filename}, URL: {file_url}, deletion URL: {deletion_url}') 77 | 78 | # Send data to Discord webhooks 79 | discord_webhooks = create_discord_webhooks(config.discord_webhook_urls, config.discord_webhook_timeout) 80 | if discord_webhooks: 81 | embed = create_uploaded_file_embed(file_url, deletion_url) 82 | execute_webhooks_with_embed(discord_webhooks, embed) 83 | 84 | # Return JSON 85 | return jsonify(url=file_url, delete_url=deletion_url) 86 | 87 | @files.get('/delete-file//') 88 | def delete_file_with_hash(hmac_hash, filename): 89 | new_hmac_hash = create_hmac_hash(filename, config.flask_secret) 90 | 91 | # If digest is invalid 92 | if safe_str_comparison(hmac_hash, new_hmac_hash) is False: 93 | abort(HTTPStatus.NOT_FOUND) 94 | 95 | file_path = safe_join(config.upload_directory, filename) 96 | file_deleted = delete_file(file_path) 97 | 98 | if file_deleted is False: 99 | abort(HTTPStatus.GONE) 100 | 101 | logger.info(f'Deleted a file {filename}') 102 | 103 | return jsonify(message='This file has been deleted, you can now close this page.') 104 | -------------------------------------------------------------------------------- /app/blueprints/main.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from flask import Blueprint, jsonify, abort, redirect, send_from_directory 4 | 5 | from app import config 6 | from app.core.urls import get_url_by_token 7 | 8 | main = Blueprint('main', __name__) 9 | 10 | @main.get('/') 11 | def index(): 12 | return jsonify(message='It works! Beep boop.') 13 | 14 | @main.get('/uploads/') 15 | def uploads(filename): 16 | return send_from_directory(config.upload_directory, filename) 17 | 18 | @main.get('/url/') 19 | def short_url(token): 20 | url = get_url_by_token(token) 21 | if url is None: 22 | abort(HTTPStatus.NOT_FOUND) 23 | return redirect(url) 24 | -------------------------------------------------------------------------------- /app/blueprints/urls.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from secrets import token_urlsafe 3 | 4 | from flask import Blueprint, jsonify, url_for, abort, request 5 | 6 | from app import config, logger 7 | from app.core.utils import ( 8 | auth_required, safe_str_comparison, 9 | create_hmac_hash 10 | ) 11 | from app.core.discord import ( 12 | create_short_url_embed, 13 | create_discord_webhooks, 14 | execute_webhooks_with_embed 15 | ) 16 | from app.core.urls import ( 17 | is_valid_url, 18 | add_https_scheme_to_url, 19 | save_url_and_token_to_database, 20 | delete_url_from_database_by_token 21 | ) 22 | 23 | urls = Blueprint('urls', __name__) 24 | 25 | @urls.get('/sharex/shorten') 26 | def shorten_config(): 27 | return jsonify({ 28 | "Name": "{} (URL shortener)".format(request.host), 29 | "Version": "1.0.0", 30 | "DestinationType": "URLShortener", 31 | "RequestMethod": "POST", 32 | "Body": "MultipartFormData", 33 | "RequestURL": url_for('urls.shorten', _external=True), 34 | "Headers": { 35 | "Authorization": "YOUR-UPLOAD-PASSWORD-HERE" 36 | }, 37 | "Arguments": { 38 | "url": "$input$" 39 | }, 40 | "URL": "$json:url$", 41 | "ErrorMessage": "$json:status$" 42 | }) 43 | 44 | @urls.post('/shorten') 45 | @auth_required 46 | def shorten(): 47 | url = request.form.get('url') 48 | 49 | if is_valid_url(url) is False: 50 | abort(HTTPStatus.BAD_REQUEST, 'Invalid URL.') 51 | 52 | url = add_https_scheme_to_url(url) 53 | token = token_urlsafe(config.url_token_bytes) 54 | 55 | saved_to_database = save_url_and_token_to_database(url, token) 56 | 57 | if saved_to_database is False: 58 | abort(HTTPStatus.INTERNAL_SERVER_ERROR, 'Unable to save URL to database.') 59 | 60 | hmac_hash = create_hmac_hash(token, config.flask_secret) 61 | shortened_url = url_for('main.short_url', token=token, _external=True) 62 | deletion_url = url_for('urls.delete_short_url_with_hash', hmac_hash=hmac_hash, token=token, _external=True) 63 | 64 | logger.info(f'Saved short URL: {shortened_url} for {url}, deletion URL: {deletion_url}') 65 | 66 | # Send data to Discord webhooks 67 | discord_webhooks = create_discord_webhooks(config.discord_webhook_urls, config.discord_webhook_timeout) 68 | if discord_webhooks: 69 | embed = create_short_url_embed(url, shortened_url, deletion_url) 70 | execute_webhooks_with_embed(discord_webhooks, embed) 71 | 72 | return jsonify(url=shortened_url) 73 | 74 | @urls.get('/delete-short-url//') 75 | def delete_short_url_with_hash(hmac_hash, token): 76 | new_hmac_hash = create_hmac_hash(token, config.flask_secret) 77 | 78 | # If digest is invalid 79 | if safe_str_comparison(hmac_hash, new_hmac_hash) is False: 80 | abort(HTTPStatus.NOT_FOUND) 81 | 82 | if delete_url_from_database_by_token(token) is False: 83 | abort(HTTPStatus.GONE) 84 | 85 | return jsonify(message='This short URL has been deleted, you can now close this page.') 86 | -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | from environs import Env 2 | from pathlib import Path 3 | from dataclasses import dataclass 4 | 5 | @dataclass(frozen=True) 6 | class Config: 7 | """Represents internal configuration for shrpy.""" 8 | 9 | # Internal secret key for Flask application. 10 | flask_secret: str 11 | 12 | # Password to protect file uploads and URL shortening endpoints. 13 | upload_password: str 14 | 15 | # Directory for file uploads. 16 | upload_directory: str 17 | 18 | # List of allowed file extensions. 19 | allowed_extensions: list[str] 20 | 21 | # List of additional custom file extensions for mimetypes module. 22 | mimetypes_custom_extensions: dict[str, str] 23 | 24 | # If server should include original filenames when saving files or not. 25 | use_original_filename: bool 26 | 27 | # List of discord webhook URLs. 28 | discord_webhook_urls: list[str] 29 | 30 | # Timeout for discord webhooks. 31 | discord_webhook_timeout: float 32 | 33 | # The amount of bytes magic module will read from file to determine its extension. 34 | magic_buffer_bytes: int 35 | 36 | # The amount of bytes for secrets.token_urlsafe which is used to generate short URLs. 37 | url_token_bytes: int 38 | 39 | @classmethod 40 | def from_env(cls, env: Env | None = None): 41 | env = env or Env() 42 | env.read_env() 43 | 44 | flask_secret = env.str('FLASK_SECRET') 45 | upload_password = env.str('UPLOAD_PASSWORD') 46 | upload_directory = env.path('UPLOAD_DIR', f'{Path.cwd()}/app/uploads') 47 | allowed_extensions = env.list('ALLOWED_EXTENSIONS', delimiter=';') 48 | mimetypes_custom_extensions = env.dict('CUSTOM_EXTENSIONS') 49 | use_original_filename = env.bool('USE_ORIGINAL_FILENAME') 50 | discord_webhook_urls = env.list('DISCORD_WEBHOOKS', delimiter=';') 51 | discord_webhook_timeout = env.float('DISCORD_WEBHOOK_TIMEOUT') 52 | magic_buffer_bytes = env.int('MAGIC_BUFFER_BYTES') 53 | url_token_bytes = env.int('URL_TOKEN_BYTES') 54 | 55 | return cls( 56 | flask_secret, 57 | upload_password, 58 | upload_directory, 59 | allowed_extensions, 60 | mimetypes_custom_extensions, 61 | use_original_filename, 62 | discord_webhook_urls, 63 | discord_webhook_timeout, 64 | magic_buffer_bytes, 65 | url_token_bytes 66 | ) 67 | -------------------------------------------------------------------------------- /app/core/discord.py: -------------------------------------------------------------------------------- 1 | # standard library imports 2 | from random import randint 3 | 4 | # pip imports 5 | from requests.exceptions import Timeout 6 | from discord_webhook import DiscordWebhook, DiscordEmbed 7 | 8 | def create_discord_webhooks(urls: list[str], timeout: float = 5.0) -> tuple[DiscordWebhook, ...]: 9 | """Creates a tuple of DiscordWebhook instances.""" 10 | filtered_urls = [url for url in urls if url.strip()] 11 | return DiscordWebhook.create_batch(filtered_urls, timeout=timeout) 12 | 13 | def create_uploaded_file_embed(url: str, deletion_url: str) -> DiscordEmbed: 14 | """Creates an instance of DiscordEmbed for uploaded files.""" 15 | embed = DiscordEmbed() 16 | embed.set_title('New file has been uploaded!') 17 | embed.set_description(url) 18 | embed.set_image(url=url) 19 | embed.set_timestamp() 20 | embed.set_color(randint(0, 0xffffff)) 21 | embed.add_embed_field(name='URL', value=f'**[Click here to view]({url})**') 22 | embed.add_embed_field(name='Deletion URL', value=f'**[Click here to delete]({deletion_url})**') 23 | return embed 24 | 25 | def create_short_url_embed(original_url: str, shortened_url: str, deletion_url: str) -> DiscordEmbed: 26 | """Creates an instance of DiscordEmbed for shortened URLs.""" 27 | embed = DiscordEmbed() 28 | embed.set_title('URL has been shortened!') 29 | embed.set_description('{} => {}'.format(original_url, shortened_url)) 30 | embed.add_embed_field(name='URL', value=f'**[Click here to view]({original_url})**') 31 | embed.add_embed_field(name='Deletion URL', value=f'**[Click here to delete]({deletion_url})**') 32 | embed.set_color(randint(0, 0xffffff)) 33 | embed.set_timestamp() 34 | return embed 35 | 36 | def execute_webhooks_with_embed(webhook_urls: tuple[DiscordWebhook, ...], embed: DiscordEmbed): 37 | """Executes a list of webhooks with given embed.""" 38 | for webhook in webhook_urls: 39 | try: 40 | webhook.add_embed(embed) 41 | webhook.execute() 42 | except Timeout: 43 | break 44 | -------------------------------------------------------------------------------- /app/core/files.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path, PurePath 2 | 3 | from uuid import uuid4 4 | from mimetypes import add_type 5 | from werkzeug.utils import secure_filename 6 | from python_magic_file import MagicFile 7 | 8 | def is_file_extension_allowed(file_extension: str , allowed_file_extensions: list) -> bool: 9 | """Returns True if given file_extension is allowed.""" 10 | extension_without_dot = file_extension.replace('.', '') 11 | return extension_without_dot in allowed_file_extensions 12 | 13 | def delete_file(file_path: str) -> bool: 14 | """Deletes a given file if it exists.""" 15 | path = Path(file_path) 16 | if path.is_file() is False: 17 | return False 18 | path.unlink() 19 | return True 20 | 21 | def get_secure_filename(arbitrary_filename: str) -> str: 22 | """Returns secure version if given arbitrary_filename.""" 23 | filename = secure_filename(arbitrary_filename) 24 | return PurePath(filename).stem 25 | 26 | def generate_filename() -> str: 27 | """Returns a random filename.""" 28 | return str(uuid4()) 29 | 30 | def get_file_extension_from_file(file_like_object, buffer: int = 2048) -> str | None: 31 | """Returns file extension from file-like object.""" 32 | magic_file = MagicFile(file_like_object) 33 | return magic_file.get_extension(buffer) 34 | 35 | def create_directory(directory: str) -> None: 36 | """Creates a given directory.""" 37 | return Path(directory).mkdir(exist_ok=True) 38 | 39 | def add_unsupported_mimetypes(mimetypes_map: dict[str, str]): 40 | """Adds unsupported mimetypes/extensions to `mimetypes` module.""" 41 | for mime, ext in mimetypes_map.items(): 42 | mime = mime.lower().strip() 43 | ext = f'.{ext.lower().strip()}' 44 | add_type(mime, ext) 45 | -------------------------------------------------------------------------------- /app/core/urls.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | from app import db 4 | 5 | def is_valid_url(url: str | None) -> bool: 6 | """Checks if given url is valid.""" 7 | if url is None: 8 | return False 9 | parsed_url = urlparse(url) 10 | if not parsed_url.netloc: 11 | return False 12 | return True 13 | 14 | def add_https_scheme_to_url(url: str) -> str: 15 | """Adds https:// to URL if needed.""" 16 | if not url.lower().startswith(('https://', 'http://')): 17 | url = f'https://{url}' 18 | return url 19 | 20 | def save_url_and_token_to_database(url: str, token: str) -> bool: 21 | """Saves url and token to database.""" 22 | cursor = db.execute("INSERT INTO urls VALUES (?, ?)", (token, url)) 23 | return cursor.rowcount > 0 24 | 25 | def delete_url_from_database_by_token(token: str) -> bool: 26 | """Deletes URL using given token from database.""" 27 | execute = db.execute("DELETE FROM urls WHERE token = ?", (token,)) 28 | return execute.rowcount > 0 29 | 30 | def get_url_by_token(token: str) -> str | None: 31 | """Returns url using given token.""" 32 | execute = db.execute("SELECT url FROM urls WHERE token = ? LIMIT 1", (token,)) 33 | result = execute.fetchone() 34 | if result is None: 35 | return None 36 | return result['url'] 37 | -------------------------------------------------------------------------------- /app/core/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from sys import stdout 3 | from hashlib import sha256 4 | from http import HTTPStatus 5 | from hmac import compare_digest, new 6 | from sqlite3 import Row, connect 7 | from functools import wraps, lru_cache 8 | 9 | from werkzeug.exceptions import HTTPException 10 | from flask import Response, jsonify, abort, request 11 | 12 | from app.core.config import Config 13 | 14 | def http_error_handler(exception: HTTPException, **kwargs) -> Response: 15 | """Error handler for `werkzeug.exceptions.HTTPException`. 16 | 17 | Args: 18 | exception (HTTPException): HTTPException instance 19 | 20 | Returns: 21 | Response: flask.Response 22 | """ 23 | response = jsonify( 24 | status_code=exception.code, 25 | status=exception.description, 26 | **kwargs 27 | ) 28 | response.status_code = exception.code 29 | return response 30 | 31 | @lru_cache(maxsize=1) 32 | def get_config() -> Config: 33 | """Returns the config.""" 34 | return Config.from_env() 35 | 36 | def auth_required(f): 37 | """Check HTTP `Authorization` header against the value of `app.config.upload.password`, calls `flask.abort` if the password does not match.""" 38 | @wraps(f) 39 | def decorated_function(*args, **kwargs): 40 | config = get_config() 41 | if config.upload_password: 42 | # Default to empty string if Authorization header is not sent 43 | authorization_header = request.headers.get('Authorization', default='') 44 | if not safe_str_comparison(config.upload_password, authorization_header): 45 | abort(HTTPStatus.UNAUTHORIZED) 46 | return f(*args, **kwargs) 47 | return decorated_function 48 | 49 | def create_stdout_logger() -> logging.Logger: 50 | """Returns stdout logger for logging.""" 51 | handler = logging.StreamHandler(stdout) 52 | handler.setFormatter( 53 | logging.Formatter('%(asctime)s | %(module)s.%(funcName)s | %(levelname)s | %(message)s') 54 | ) 55 | 56 | logger = logging.getLogger('shrpy') 57 | logger.addHandler(handler) 58 | logger.setLevel(logging.INFO) 59 | 60 | return logger 61 | 62 | def create_hmac_hash(hmac_payload: str, hmac_secret_key: str) -> str: 63 | """Returns sha256 HMAC hexdigest.""" 64 | 65 | if hmac_secret_key is None: 66 | raise RuntimeError('hmac_secret_key cannot be None, please set a secret for the application in dotenv file!') 67 | 68 | return new( 69 | hmac_secret_key.encode('utf-8'), 70 | hmac_payload.encode('utf-8'), 71 | sha256 72 | ).hexdigest() 73 | 74 | def setup_db(): 75 | """Connects to SQLite3 database and returns the connection cursor.""" 76 | connection = connect('shrpy.db', check_same_thread=False) 77 | connection.isolation_level = None 78 | connection.row_factory = Row 79 | 80 | cursor = connection.cursor() 81 | cursor.execute("CREATE TABLE IF NOT EXISTS urls (token VARCHAR(10) NOT NULL PRIMARY KEY, url TEXT NOT NULL)") 82 | 83 | return cursor 84 | 85 | def safe_str_comparison(a: str, b: str) -> bool: 86 | """Performs safe string comparison.""" 87 | return compare_digest(a, b) 88 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask == 3.0.3 2 | discord-webhook == 1.3.1 3 | environs == 11.0.0 4 | python-magic-file == 0.0.7 5 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | 3 | # WSGI instance 4 | application = create_app() 5 | 6 | if __name__ == '__main__': 7 | application.debug = True 8 | application.run() 9 | --------------------------------------------------------------------------------