├── .dockerignore ├── templates ├── includes │ ├── max_file_size.txt │ ├── supported_types.txt │ └── user_mention.txt ├── bot │ ├── err-url-upload-failed.txt │ ├── err-url-upload-rejected.txt │ ├── err-file-too-big.txt │ ├── err-no-file.txt │ ├── ok-links.txt │ └── ok-help.txt └── web │ ├── error.html │ ├── donate.html │ ├── upload.html │ ├── main.html │ ├── file-links.html │ ├── layout.html │ └── docs-api.html ├── static ├── favicon.ico ├── favicon.png ├── sourcesanspro-italic.woff2 ├── sourcecodepro-regular.woff2 ├── sourcesanspro-regular.woff2 ├── OFL-SourceSansPro.txt ├── OFL-SourceCodePro.txt └── style.css ├── requirements.opm ├── app ├── views │ ├── main.lua │ ├── docs-api.lua │ ├── donate.lua │ ├── helpers.lua │ ├── error.lua │ ├── init.lua │ ├── upload.lua │ ├── get-file.lua │ └── webhook.lua ├── init.lua ├── phases │ └── header_filter.lua ├── constants.lua ├── cipher.lua ├── config.lua ├── uploader │ ├── url │ │ ├── form.lua │ │ ├── mixin.lua │ │ └── direct.lua │ ├── file │ │ ├── direct.lua │ │ ├── mixin.lua │ │ └── form.lua │ └── base.lua ├── tinyid.lua ├── mediatypes.lua ├── tg.lua └── utils.lua ├── .gitignore ├── .luacheckrc ├── justfile ├── .editorconfig ├── Dockerfile ├── .github └── FUNDING.yml ├── LICENSE ├── tinysta.sh ├── commands ├── command-runner.sh ├── conf.lua └── webhook.lua ├── nginx.conf.tpl ├── config.example.lua └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | static/OFL-*.txt 2 | config.lua 3 | nginx.conf 4 | -------------------------------------------------------------------------------- /templates/includes/max_file_size.txt: -------------------------------------------------------------------------------- 1 | Maximum file size is 20 MiB. 2 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-def/tinystash/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-def/tinystash/HEAD/static/favicon.png -------------------------------------------------------------------------------- /templates/bot/err-url-upload-failed.txt: -------------------------------------------------------------------------------- 1 | {( includes/user_mention.txt )} 2 | Telegram failed to process URL. 3 | -------------------------------------------------------------------------------- /static/sourcesanspro-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-def/tinystash/HEAD/static/sourcesanspro-italic.woff2 -------------------------------------------------------------------------------- /templates/bot/err-url-upload-rejected.txt: -------------------------------------------------------------------------------- 1 | {( includes/user_mention.txt )} 2 | Telegram rejected to process URL. 3 | -------------------------------------------------------------------------------- /static/sourcecodepro-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-def/tinystash/HEAD/static/sourcecodepro-regular.woff2 -------------------------------------------------------------------------------- /static/sourcesanspro-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-def/tinystash/HEAD/static/sourcesanspro-regular.woff2 -------------------------------------------------------------------------------- /templates/bot/err-file-too-big.txt: -------------------------------------------------------------------------------- 1 | {( includes/user_mention.txt )} 2 | The file is too big. {( includes/max_file_size.txt )} 3 | -------------------------------------------------------------------------------- /requirements.opm: -------------------------------------------------------------------------------- 1 | ledgetech/lua-resty-http=0.16.1 2 | bungle/lua-resty-template=2.0 3 | un-def/lua-basex=0.2.0 4 | un-def/httoolsp=0.3.0 5 | -------------------------------------------------------------------------------- /templates/bot/err-no-file.txt: -------------------------------------------------------------------------------- 1 | {( includes/user_mention.txt )} 2 | There is no file or URL in your message. See /help for supported content types. 3 | -------------------------------------------------------------------------------- /templates/includes/supported_types.txt: -------------------------------------------------------------------------------- 1 | Currently supported content types include: photos, audio, voice notes, videos, video notes, stickers, and documents (regular files). 2 | -------------------------------------------------------------------------------- /app/views/main.lua: -------------------------------------------------------------------------------- 1 | local tg_bot_username = require('app.config').tg.bot_username 2 | 3 | 4 | return { 5 | 6 | GET = {'web/main.html', { 7 | bot_username = tg_bot_username, 8 | }} 9 | 10 | } 11 | -------------------------------------------------------------------------------- /app/init.lua: -------------------------------------------------------------------------------- 1 | return { 2 | _VERSION = '2.3.0', 3 | _DESCRIPTION = 'A storage-less database-less file sharing service powered by OpenResty', 4 | _URL = 'https://github.com/un-def/tinystash', 5 | _LICENSE = 'MIT', 6 | } 7 | -------------------------------------------------------------------------------- /templates/includes/user_mention.txt: -------------------------------------------------------------------------------- 1 | {% if user then %} 2 | [{% if user.username then %}@{* user.username *}{% else %}{* user.first_name *}{* user.last_name and ' ' .. user.last_name *}{% end %}](tg://user?id={* user.id *}) 3 | {% end %} 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | !.dockerignore 4 | !.editorconfig 5 | !.luacheckrc 6 | 7 | *_temp/ 8 | logs/ 9 | resty_modules/ 10 | nginx.pid 11 | 12 | /nginx.conf 13 | /config.lua 14 | 15 | *.sh 16 | !/tinysta.sh 17 | !/commands/command-runner.sh 18 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | std = 'ngx_lua' 2 | codes = true 3 | exclude_files = { 4 | 'resty_modules/**', 5 | 'config.lua', 6 | } 7 | files['commands'] = { 8 | read_globals = { 9 | 'OPENRESTY_PREFIX', 10 | 'TINYSTASH_DIR', 11 | 'run_command', 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /templates/bot/ok-links.txt: -------------------------------------------------------------------------------- 1 | {( includes/user_mention.txt )} 2 | *Inline link (view in browser)*: 3 | {* render_link(modes.INLINE) *}{* extension *} 4 | 5 | {% if not hide_download_link then %} 6 | *Download link*: 7 | {* render_link(modes.DOWNLOAD) *}{* extension *} 8 | 9 | {% end %} 10 | *Links page*: 11 | {* render_link(modes.LINKS) *} 12 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | project := 'tinystash' 2 | 3 | _list: 4 | @just --list --unsorted 5 | 6 | install-deps: 7 | opm --cwd get $(cat requirements.opm) 8 | 9 | lint: 10 | luacheck . 11 | 12 | run: 13 | ./tinysta.sh run 14 | 15 | build: 16 | docker build . \ 17 | --pull --no-cache \ 18 | --tag "{{project}}:$(date '+%y%m%d%H%M')" --tag "{{project}}:latest" 19 | -------------------------------------------------------------------------------- /templates/web/error.html: -------------------------------------------------------------------------------- 1 | {% local layout = 'web/layout.html' %} 2 |
3 |
4 | [ {{ status }}{% if reason then %} {{ reason }}{% end %} ] 5 |
6 | {% if description then %} 7 |
8 | Error: {{ description }} 9 |
10 | {% end %} 11 |
12 | -------------------------------------------------------------------------------- /app/views/docs-api.lua: -------------------------------------------------------------------------------- 1 | local error = require('app.utils').error 2 | local enable_upload_api = require('app.config')._processed.enable_upload_api 3 | 4 | 5 | local ngx_HTTP_NOT_FOUND = ngx.HTTP_NOT_FOUND 6 | 7 | 8 | return { 9 | 10 | initial = function() 11 | if not enable_upload_api then 12 | return error(ngx_HTTP_NOT_FOUND) 13 | end 14 | end, 15 | 16 | GET = 'web/docs-api.html', 17 | 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [{*.lua,.luacheckrc}] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{*.html,*.css,*.txt}] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.conf] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [justfile] 22 | indent_style = space 23 | indent_size = 2 24 | 25 | [*.sh] 26 | indent_style = space 27 | indent_size = 2 28 | -------------------------------------------------------------------------------- /app/views/donate.lua: -------------------------------------------------------------------------------- 1 | local error = require('app.utils').error 2 | local donate_config = require('app.config').donate 3 | 4 | 5 | local ngx_HTTP_NOT_FOUND = ngx.HTTP_NOT_FOUND 6 | 7 | local enable_donate = donate_config.enable 8 | 9 | 10 | return { 11 | 12 | initial = function() 13 | if not enable_donate then 14 | return error(ngx_HTTP_NOT_FOUND) 15 | end 16 | end, 17 | 18 | GET = {'web/donate.html', { 19 | intro = donate_config.intro, 20 | blocks = donate_config.blocks, 21 | }} 22 | 23 | } 24 | -------------------------------------------------------------------------------- /templates/bot/ok-help.txt: -------------------------------------------------------------------------------- 1 | *Hi! I'm tiny[stash] bot.* 2 | 3 | Send me any content or URL, and I'll generate a public HTTP link for it. 4 | 5 | {( includes/supported_types.txt )} 6 | {% if enable_upload then %} 7 | You can also upload any [file]({* link_url_prefix *}/upload/file), [text]({* link_url_prefix *}/upload/text), or [URL]({* link_url_prefix *}/upload/url) directly with your browser{% if enable_upload_api then %} or [HTTP client]({* link_url_prefix *}/docs/api){% end %}. 8 | 9 | {% end %} 10 | {( includes/max_file_size.txt )} 11 | -------------------------------------------------------------------------------- /templates/web/donate.html: -------------------------------------------------------------------------------- 1 | {% local layout = 'web/layout.html' %} 2 | 20 | -------------------------------------------------------------------------------- /app/phases/header_filter.lua: -------------------------------------------------------------------------------- 1 | local ngx_header = ngx.header 2 | 3 | 4 | local _M = {} 5 | 6 | _M.deny_page_framing = function() 7 | -- block framing any html page, excluding uploaded files (detected by 8 | -- the presense of the 'content-disposition' header) 9 | if not ngx_header['content-disposition'] and ngx_header['content-type'] == 'text/html' then 10 | -- for modern browsers 11 | -- https://caniuse.com/#feat=mdn-http_headers_csp_content-security-policy_frame-ancestors 12 | ngx_header['content-security-policy'] = "frame-ancestors 'none'" 13 | -- for older browsers 14 | ngx_header['x-frame-options'] = 'deny' 15 | end 16 | end 17 | 18 | return _M 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openresty/openresty:alpine AS builder 2 | WORKDIR /opt/tinystash/ 3 | RUN apk add --no-cache curl perl 4 | COPY requirements.opm /tmp/requirements.opm 5 | RUN opm --cwd get $(cat /tmp/requirements.opm) 6 | COPY app/ app/ 7 | COPY static/ static/ 8 | COPY templates/ templates/ 9 | COPY commands/ commands/ 10 | COPY tinysta.sh tinysta.sh 11 | COPY nginx.conf.tpl nginx.conf.tpl 12 | 13 | FROM openresty/openresty:alpine-apk 14 | WORKDIR /opt/tinystash/ 15 | RUN apk add --no-cache ca-certificates 16 | COPY --from=builder /opt/tinystash/ ./ 17 | 18 | EXPOSE 80 19 | 20 | ENTRYPOINT ["./tinysta.sh"] 21 | CMD ["run"] 22 | 23 | LABEL maintainer="Dmitry Meyer " 24 | LABEL version="2.3.0" 25 | -------------------------------------------------------------------------------- /templates/web/upload.html: -------------------------------------------------------------------------------- 1 | {% local layout = 'web/layout.html' %} 2 |
3 |
4 | 5 | {% if upload_type == 'file' then %} 6 | 7 | {% elseif upload_type == 'text' then %} 8 | 9 | {% elseif upload_type == 'url' then %} 10 | 11 | {% end %} 12 | 13 |
14 |
15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: un1def 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: ['https://tinystash.undef.im/donate'] 15 | -------------------------------------------------------------------------------- /templates/web/main.html: -------------------------------------------------------------------------------- 1 | {% local layout = 'web/layout.html' %} 2 |
3 |
4 |

tiny[stash] is the easiest way[citation needed] to share files from Telegram to the public internet.

5 |

Just send anything1,2 to @{{ bot_username }} Telegram bot and get public HTTP link.

6 |
7 | {% if enable_upload then %} 8 |
9 |

You can also upload any1 file, text, or URL directly with your browser{% if enable_upload_api then %} or HTTP client{% end %}.

10 |
11 | {% end %} 12 |
13 |

1 {( includes/max_file_size.txt )}

14 |

2 {( includes/supported_types.txt )}

15 |
16 |
17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2021, 2024 Dmitry Meyer 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 | -------------------------------------------------------------------------------- /app/constants.lua: -------------------------------------------------------------------------------- 1 | local _M = {} 2 | 3 | 4 | local AUDIO = 'audio' 5 | local VOICE = 'voice' 6 | local VIDEO = 'video' 7 | local VIDEO_NOTE = 'video_note' 8 | local PHOTO = 'photo' 9 | local STICKER = 'sticker' 10 | local DOCUMENT = 'document' 11 | 12 | 13 | _M.TG_TYPES = { 14 | AUDIO = AUDIO, 15 | VOICE = VOICE, 16 | VIDEO = VIDEO, 17 | VIDEO_NOTE = VIDEO_NOTE, 18 | PHOTO = PHOTO, 19 | STICKER = STICKER, 20 | DOCUMENT = DOCUMENT, 21 | } 22 | 23 | _M.TG_TYPES_EXTENSIONS_MAP = { 24 | [VOICE] = 'ogg', -- it should be .opus, but nobody respects RFCs 25 | [VIDEO] = 'mp4', 26 | [VIDEO_NOTE] = 'mp4', 27 | [PHOTO] = 'jpg', 28 | } 29 | 30 | _M.TG_TYPES_MEDIA_TYPES_MAP = { 31 | [VOICE] = 'audio/ogg', 32 | [VIDEO] = 'video/mp4', 33 | [VIDEO_NOTE] = 'video/mp4', 34 | [PHOTO] = 'image/jpeg', 35 | } 36 | 37 | _M.TG_CHAT_PRIVATE = 'private' 38 | 39 | _M.TG_API_HOST = 'api.telegram.org' 40 | 41 | _M.TG_MAX_FILE_SIZE = 20971520 42 | 43 | _M.GET_FILE_MODES = { 44 | DOWNLOAD = 'dl', 45 | INLINE = 'il', 46 | LINKS = 'ln', 47 | } 48 | 49 | _M.CHUNK_SIZE = 8192 50 | 51 | _M.CSRFTOKEN_FIELD_NAME = 'csrftoken' 52 | 53 | _M.DOWNSTREAM_TIMEOUT = 10000 54 | 55 | 56 | return _M 57 | -------------------------------------------------------------------------------- /tinysta.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | OPENRESTY_PREFIX=${OPENRESTY_PREFIX:-/usr/local/openresty} 4 | export OPENRESTY_PREFIX 5 | TINYSTASH_DIR=$(dirname "$(readlink -f "${0}")") 6 | export TINYSTASH_DIR 7 | TINYSTASH_CONFIG_PATH=$(readlink -f "${TINYSTASH_CONFIG_PATH:-"${TINYSTASH_DIR}/config.lua"}") 8 | export TINYSTASH_CONFIG_PATH 9 | 10 | command_runner="${TINYSTASH_DIR}/commands/command-runner.sh" 11 | nginx_conf="${TINYSTASH_DIR}/nginx.conf" 12 | 13 | usage() { 14 | echo " 15 | usage: ${0} COMMAND [ARGS] 16 | 17 | commands: 18 | run 19 | conf 20 | webhook 21 | " 22 | exit 1 23 | } 24 | 25 | if [ "${#}" -eq 0 ] || [ "${1}" = '-h' ]; then 26 | usage 27 | fi 28 | 29 | command=${1} 30 | shift 31 | 32 | case "${command}" in 33 | conf|webhook) 34 | echo "using config: ${TINYSTASH_CONFIG_PATH}" 35 | echo 36 | exec "${command_runner}" "${command}" "${@}" 37 | ;; 38 | run) 39 | echo "using config: ${TINYSTASH_CONFIG_PATH}" 40 | echo 41 | if ! nginx_conf_content=$("${command_runner}" conf); then 42 | echo "error while generating nginx.conf:" 43 | echo "${nginx_conf_content}" 44 | exit 2 45 | fi 46 | echo "${nginx_conf_content}" > "${nginx_conf}" 47 | exec "${OPENRESTY_PREFIX}/nginx/sbin/nginx" -c "${nginx_conf}" -p "${TINYSTASH_DIR}" 48 | ;; 49 | *) 50 | usage 51 | esac 52 | -------------------------------------------------------------------------------- /app/cipher.lua: -------------------------------------------------------------------------------- 1 | local ffi = require('ffi') 2 | 3 | local aes = require('resty.aes') 4 | local aes_params = require('app.config').aes 5 | 6 | local string_format = string.format 7 | 8 | local ffi_new = ffi.new 9 | local ffi_string = ffi.string 10 | local C = ffi.C 11 | 12 | 13 | ffi.cdef[[ 14 | unsigned long ERR_get_error(void); 15 | void ERR_error_string_n(unsigned long e, char *buf, size_t len); 16 | ]] 17 | 18 | 19 | local _M = {} 20 | 21 | -- https://github.com/openresty/lua-resty-string/pull/65 22 | local get_error = function(op) 23 | local errno = C.ERR_get_error() 24 | if errno == 0 then 25 | return nil 26 | end 27 | local msg = ffi_new('char[?]', 256) 28 | C.ERR_error_string_n(errno, msg, 256) 29 | return string_format('AES %s error: %s', op, ffi_string(msg)) 30 | end 31 | 32 | local aes_obj = aes:new( 33 | aes_params.key, 34 | aes_params.salt, 35 | aes.cipher(aes_params.size, aes_params.mode), 36 | aes_params.hash and aes.hash[aes_params.hash], 37 | aes_params.hash_rounds 38 | ) 39 | 40 | _M.encrypt = function(data) 41 | data = aes_obj:encrypt(data) 42 | if not data then 43 | return nil, get_error('encrypt') 44 | end 45 | return data 46 | end 47 | 48 | _M.decrypt = function(data) 49 | data = aes_obj:decrypt(data) 50 | if not data then 51 | return nil, get_error('decrypt') 52 | end 53 | return data 54 | end 55 | 56 | return _M 57 | -------------------------------------------------------------------------------- /app/config.lua: -------------------------------------------------------------------------------- 1 | local config_path = assert(os.getenv('TINYSTASH_CONFIG_PATH'), 'TINYSTASH_CONFIG_PATH is not set') 2 | local chunk = assert(loadfile(config_path)) 3 | local _, config = assert(pcall(chunk)) 4 | 5 | local _processed = {} 6 | -- config._processed contains some calculated/normalized values (for the sake of convenience and brevity) 7 | config._processed = _processed 8 | 9 | local config_tg = config.tg 10 | 11 | local link_url_prefix = config.link_url_prefix:match('(.-)/*$') 12 | 13 | 14 | local _, url_path_start = link_url_prefix:find('^https?://[^/]+') 15 | local url_path_prefix 16 | if url_path_start then 17 | url_path_prefix = link_url_prefix:sub(url_path_start + 1) 18 | else 19 | url_path_prefix = '' 20 | end 21 | 22 | -- config.link_url_prefix without trailing slash(es) 23 | _processed.link_url_prefix = link_url_prefix 24 | -- path component of link_url_prefix without trailing slash(es) (include single slash, 25 | -- i.e. 'http://example.com/' -> '', 'http://example.com/path/to/' -> '/path/to') 26 | _processed.url_path_prefix = url_path_prefix 27 | -- enable upload (both via html form and via direct upload api) 28 | _processed.enable_upload = config_tg.upload_chat_id ~= nil 29 | -- config.enable_upload_api corrected accordind to config._processed.enable_upload 30 | _processed.enable_upload_api = _processed.enable_upload and config.enable_upload_api 31 | -- tg webhook secret, either set explicitly (arbitrary string) or implicitly (bot api token) 32 | _processed.tg_webhook_secret = config_tg.webhook_secret or config_tg.token 33 | 34 | return config 35 | -------------------------------------------------------------------------------- /app/views/helpers.lua: -------------------------------------------------------------------------------- 1 | local process_file = require('resty.template').new({ 2 | root = 'templates', 3 | }).process_file 4 | 5 | local config = require('app.config') 6 | 7 | 8 | local ngx_print = ngx.print 9 | 10 | local link_url_prefix = config._processed.link_url_prefix 11 | local url_path_prefix = config._processed.url_path_prefix 12 | local enable_upload = config._processed.enable_upload 13 | local enable_upload_api = config._processed.enable_upload_api 14 | local enable_donate = config.donate.enable 15 | 16 | 17 | local _M = {} 18 | 19 | _M.render_link_factory = function(tiny_id) 20 | local link_template = ('%s/%%s/%s'):format(link_url_prefix, tiny_id) 21 | return function(mode) 22 | return link_template:format(mode) 23 | end 24 | end 25 | 26 | local render_to_string = function(template_path, context) 27 | local full_context = { 28 | link_url_prefix = link_url_prefix, 29 | url_path_prefix = url_path_prefix, 30 | enable_upload = enable_upload, 31 | enable_upload_api = enable_upload_api, 32 | enable_donate = enable_donate, 33 | } 34 | if type(context) == 'table' then 35 | for k, v in pairs(context) do 36 | full_context[k] = v 37 | end 38 | end 39 | return process_file(template_path, full_context) 40 | end 41 | 42 | _M.render_to_string = render_to_string 43 | 44 | _M.render = function(template_path, context) 45 | ngx_print(render_to_string(template_path, context)) 46 | end 47 | 48 | local markdown_escape_cb = function(char) 49 | return ([[\%s]]):format(char) 50 | end 51 | 52 | _M.markdown_escape = function(text) 53 | return text:gsub('[_*`[]', markdown_escape_cb) 54 | end 55 | 56 | return _M 57 | -------------------------------------------------------------------------------- /app/uploader/url/form.lua: -------------------------------------------------------------------------------- 1 | local utils = require('app.utils') 2 | local base = require('app.uploader.base') 3 | local url_mixin = require('app.uploader.url.mixin') 4 | 5 | local ngx_HTTP_FORBIDDEN = ngx.HTTP_FORBIDDEN 6 | local ngx_HTTP_BAD_REQUEST = ngx.HTTP_BAD_REQUEST 7 | local ngx_req = ngx.req 8 | 9 | local log = utils.log 10 | local is_http_url = utils.is_http_url 11 | 12 | 13 | local uploader = base.build_uploader(url_mixin, base.form_mixin, base.uploader) 14 | 15 | uploader.new = function(self, _upload_type, chat_id, headers) 16 | local csrftoken, err = self:extract_csrftoken_from_cookies(headers) 17 | if not csrftoken then 18 | log(err) 19 | return nil, ngx_HTTP_BAD_REQUEST 20 | end 21 | return setmetatable({ 22 | chat_id = chat_id, 23 | csrftoken = csrftoken, 24 | }, uploader) 25 | end 26 | 27 | uploader.run = function(self) 28 | -- sets: 29 | -- self.client: tg.client (via upload) 30 | -- self.media_type: string (via upload) 31 | -- self.bytes_uploaded: int (via upload) 32 | ngx_req.read_body() 33 | local args, err = ngx_req.get_post_args() 34 | if err then 35 | log(err) 36 | return nil, ngx_HTTP_BAD_REQUEST 37 | end 38 | local ok 39 | ok, err = self:check_csrftoken(args.csrftoken) 40 | if not ok then 41 | log(err) 42 | return nil, ngx_HTTP_FORBIDDEN 43 | end 44 | local url = args.url 45 | log('url: %s', url) 46 | if url == nil then 47 | log('no url') 48 | return nil, ngx_HTTP_BAD_REQUEST 49 | elseif type(url) == 'table' then 50 | log('multiple url fields') 51 | return nil, ngx_HTTP_BAD_REQUEST 52 | elseif not is_http_url(url) then 53 | log('invalid url') 54 | return nil, ngx_HTTP_BAD_REQUEST, 'invalid URL' 55 | end 56 | return self:upload(url) 57 | end 58 | 59 | return uploader 60 | -------------------------------------------------------------------------------- /templates/web/file-links.html: -------------------------------------------------------------------------------- 1 | {% 2 | local layout = 'web/layout.html' 3 | local ln_link = render_link(modes.LINKS) 4 | local dl_link = render_link(modes.DOWNLOAD) 5 | local il_link = render_link(modes.INLINE) 6 | %} 7 | 52 | -------------------------------------------------------------------------------- /app/tinyid.lua: -------------------------------------------------------------------------------- 1 | local base58 = require('basex').base58bitcoin 2 | 3 | local cipher = require('app.cipher') 4 | local mediatypes = require('app.mediatypes') 5 | local utils = require('app.utils') 6 | 7 | local cipher_encrypt = cipher.encrypt 8 | local cipher_decrypt = cipher.decrypt 9 | 10 | local DEFAULT_TYPE_ID = mediatypes.DEFAULT_TYPE_ID 11 | local ID_TYPE_MAP = mediatypes.ID_TYPE_MAP 12 | local decode_urlsafe_base64 = utils.decode_urlsafe_base64 13 | local encode_urlsafe_base64 = utils.encode_urlsafe_base64 14 | local get_substring = utils.get_substring 15 | 16 | 17 | local _M = {} 18 | 19 | _M.encode = function(params) 20 | local file_id_bytes, err = decode_urlsafe_base64(params.file_id) 21 | if not file_id_bytes then 22 | return nil, err 23 | end 24 | local file_id_size_byte = string.char(#file_id_bytes) 25 | local media_type_byte = string.char(params.media_type_id or DEFAULT_TYPE_ID) 26 | local tiny_id_raw_bytes = table.concat{ 27 | file_id_size_byte, 28 | file_id_bytes, 29 | media_type_byte, 30 | } 31 | local tiny_id_encr_bytes = cipher_encrypt(tiny_id_raw_bytes) 32 | return base58:encode(tiny_id_encr_bytes) 33 | end 34 | 35 | _M.decode = function(tiny_id) 36 | -- decrypt tiny_id 37 | local tiny_id_encr_bytes, err = base58:decode(tiny_id) 38 | if not tiny_id_encr_bytes then 39 | return nil, err 40 | end 41 | local tiny_id_raw_bytes, err = cipher_decrypt(tiny_id_encr_bytes) -- luacheck: ignore 411 42 | if not tiny_id_raw_bytes then 43 | return nil, err 44 | end 45 | -- get file_id size 46 | local file_id_size = string.byte(tiny_id_raw_bytes:sub(1, 1)) 47 | if not file_id_size or file_id_size < 1 then 48 | return nil, 'Wrong file_id size' 49 | end 50 | -- get file_id 51 | local file_id_bytes, pos 52 | file_id_bytes, pos = get_substring(tiny_id_raw_bytes, 2, file_id_size) 53 | if #file_id_bytes < file_id_size then 54 | return nil, 'file_id size less than declared' 55 | end 56 | local file_id = encode_urlsafe_base64(file_id_bytes) 57 | -- get media_type 58 | local media_type = nil 59 | local media_type_id = nil 60 | if pos then 61 | local media_type_byte = get_substring(tiny_id_raw_bytes, pos, 1) 62 | media_type_id = string.byte(media_type_byte) 63 | if media_type_id ~= DEFAULT_TYPE_ID then 64 | media_type = ID_TYPE_MAP[media_type_id] 65 | end 66 | end 67 | 68 | return { 69 | file_id = file_id, 70 | media_type_id = media_type_id, 71 | media_type = media_type, 72 | } 73 | end 74 | 75 | return _M 76 | -------------------------------------------------------------------------------- /app/uploader/url/mixin.lua: -------------------------------------------------------------------------------- 1 | local tg = require('app.tg') 2 | local utils = require('app.utils') 3 | 4 | local ngx_HTTP_BAD_GATEWAY = ngx.HTTP_BAD_GATEWAY 5 | local ngx_ERR = ngx.ERR 6 | local ngx_INFO = ngx.INFO 7 | 8 | local URL_UPLOAD_ERROR_FAILED = tg.URL_UPLOAD_ERROR_FAILED 9 | local URL_UPLOAD_ERROR_REJECTED = tg.URL_UPLOAD_ERROR_REJECTED 10 | local tg_client = tg.client 11 | local get_file_from_message = tg.get_file_from_message 12 | local get_url_upload_error_type = tg.get_url_upload_error_type 13 | 14 | local guess_media_type = utils.guess_media_type 15 | local log = utils.log 16 | 17 | 18 | local _get_error_message_from_tg_error = function(err) 19 | local err_type = get_url_upload_error_type(err) 20 | if err_type == URL_UPLOAD_ERROR_FAILED then 21 | return 'upstream failed to get URL content' 22 | end 23 | if err_type == URL_UPLOAD_ERROR_REJECTED then 24 | return 'upstream rejected to process URL' 25 | end 26 | return nil 27 | end 28 | 29 | 30 | local mixin = {} 31 | 32 | mixin.upload = function(self, url) 33 | -- params: 34 | -- url: string 35 | -- returns: 36 | -- if ok: TG API object (Document/Video/...) table with mandatory 'file_id' field 37 | -- if error: nil, error_code, error_text? 38 | -- sets: 39 | -- self.client: tg.client 40 | -- self.media_type: string 41 | -- self.bytes_uploaded: int 42 | local client = tg_client() 43 | self.client = client 44 | local resp, err = client:send_document({ 45 | chat_id = self.chat_id, 46 | document = url, 47 | }) 48 | if err then 49 | log(ngx_ERR, 'tg api request error: %s', err) 50 | return nil, ngx_HTTP_BAD_GATEWAY 51 | end 52 | if not resp.ok then 53 | local tg_err = resp.description 54 | log(ngx_INFO, 'tg api response is not "ok": %s', tg_err) 55 | return nil, ngx_HTTP_BAD_GATEWAY, _get_error_message_from_tg_error(tg_err) 56 | end 57 | if not resp.result then 58 | log(ngx_INFO, 'tg api response has no "result"') 59 | return nil, ngx_HTTP_BAD_GATEWAY 60 | end 61 | local file 62 | file, err = get_file_from_message(resp.result) 63 | if not file then 64 | log(ngx_INFO, err) 65 | return nil, ngx_HTTP_BAD_GATEWAY 66 | end 67 | local file_object = file.object 68 | if not file_object.file_id then 69 | log(ngx_INFO, 'tg api response has no "file_id"') 70 | return nil, ngx_HTTP_BAD_GATEWAY 71 | end 72 | local _, media_type = guess_media_type(file_object, file.type) 73 | self:set_media_type(media_type) 74 | self.bytes_uploaded = file_object.file_size 75 | return file_object 76 | end 77 | 78 | return mixin 79 | -------------------------------------------------------------------------------- /templates/web/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% if title then %}{{ title }}{* ' — ' *}{% end %}tiny[stash] 12 | 13 | 14 |
15 |
16 | 19 | 34 |
35 |
36 | {* view *} 37 |
38 |
39 |
40 | Created by un.def 41 | 42 | Source code 43 | 44 | Issues 45 |
46 |
47 | Written in Lua 48 | 49 | Powered by LuaJIT and OpenResty 50 |
51 |
52 | NoScript-friendly 53 | 54 | 0% JavaScript 55 |
56 |
57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /app/views/error.lua: -------------------------------------------------------------------------------- 1 | local json_encode = require('cjson.safe').encode 2 | local parse_accept_header = require('httoolsp.headers').parse_accept_header 3 | 4 | local render_to_string = require('app.views.helpers').render_to_string 5 | 6 | 7 | local ngx_say = ngx.say 8 | local ngx_exit = ngx.exit 9 | local ngx_req = ngx.req 10 | local ngx_header = ngx.header 11 | local ngx_HTTP_OK = ngx.HTTP_OK 12 | 13 | 14 | local REASONS = { 15 | [400] = 'Bad Request', 16 | [403] = 'Forbidden', 17 | [404] = 'Not Found', 18 | [405] = 'Method Not Allowed', 19 | [413] = 'Payload Too Large', 20 | [411] = 'Length Required', 21 | [500] = 'Internal Server Error', 22 | [501] = 'Not Implemented', 23 | [502] = 'Bad Gateway', 24 | } 25 | 26 | 27 | local MEDIA_TYPES = { 28 | 'text/html', 29 | 'application/json', 30 | 'text/plain', 31 | } 32 | 33 | local DEFAULT_MEDIA_TYPE = MEDIA_TYPES[1] 34 | 35 | 36 | local error_page_cache = {} 37 | 38 | 39 | local error_handler = function() 40 | local args = ngx_req.get_uri_args() 41 | local status = tonumber(args.status or ngx.status) 42 | ngx.status = status 43 | local description = args.description 44 | 45 | local content_type 46 | local accept_header = ngx_req.get_headers()['accept'] 47 | if type(accept_header) == 'table' then 48 | accept_header = accept_header[1] 49 | end 50 | if accept_header then 51 | content_type = parse_accept_header(accept_header):negotiate(MEDIA_TYPES) 52 | end 53 | if not content_type then 54 | content_type = DEFAULT_MEDIA_TYPE 55 | end 56 | ngx_header['content-type'] = content_type 57 | 58 | if content_type == 'text/plain' then 59 | ngx_say('ERROR: ', description or status) 60 | return ngx_exit(ngx_HTTP_OK) 61 | elseif content_type == 'application/json' then 62 | ngx_say(json_encode{ 63 | error_code = status, 64 | error_description = description, 65 | }) 66 | return ngx_exit(ngx_HTTP_OK) 67 | else 68 | -- do not cache error pages with arbitrary descriptions 69 | local cacheable = not description 70 | if cacheable then 71 | local cached = error_page_cache[status] 72 | if cached then 73 | ngx_say(cached) 74 | return ngx_exit(ngx_HTTP_OK) 75 | end 76 | end 77 | local content = render_to_string('web/error.html', { 78 | title = status, 79 | status = status, 80 | reason = REASONS[status], 81 | description = description, 82 | }) 83 | if cacheable then 84 | error_page_cache[status] = content 85 | end 86 | ngx_say(content) 87 | return ngx_exit(ngx_HTTP_OK) 88 | end 89 | end 90 | 91 | 92 | return { 93 | REASONS = REASONS, 94 | error_handler = error_handler, 95 | } 96 | -------------------------------------------------------------------------------- /commands/command-runner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # inspired by resty-cli -- https://github.com/openresty/resty-cli 4 | 5 | die() { 6 | test ${#} -ne 0 && echo "${@}" 7 | exit 1 8 | } 9 | 10 | check_is_set() { 11 | test -n "${2}" && return 12 | die "${1} is not set" 13 | } 14 | 15 | check_is_set OPENRESTY_PREFIX "${OPENRESTY_PREFIX}" 16 | check_is_set TINYSTASH_DIR "${TINYSTASH_DIR}" 17 | test ${#} -ge 1 || die "usage: ${0} COMMAND [ARGS]" 18 | 19 | COMMANDS_DIR="${COMMANDS_DIR:-"${TINYSTASH_DIR}/commands"}" 20 | command_name=${1} 21 | shift 22 | 23 | nginx_root=$(mktemp -d) 24 | 25 | cleanup() { 26 | rm -rf "${nginx_root}" 27 | trap - EXIT 28 | } 29 | 30 | trap cleanup EXIT INT QUIT TERM HUP 31 | 32 | mkdir "${nginx_root}/logs" 33 | nginx_conf="${nginx_root}/nginx.conf" 34 | 35 | make_argv() { 36 | echo "local argv = {" 37 | while [ ${#} -ne 0 ] 38 | do 39 | echo " [===[${1}]===]," 40 | shift 41 | done 42 | echo " }" 43 | } 44 | 45 | cat > "${nginx_conf}" << __EOF__ 46 | daemon off; 47 | master_process off; 48 | worker_processes 1; 49 | pid logs/nginx.pid; 50 | env TINYSTASH_CONFIG_PATH; 51 | error_log stderr warn; 52 | events { 53 | worker_connections 64; 54 | } 55 | http { 56 | access_log off; 57 | resolver 8.8.8.8 ipv6=off; 58 | lua_package_path "${TINYSTASH_DIR}/?.lua;${TINYSTASH_DIR}/resty_modules/lualib/?.lua;;"; 59 | lua_package_cpath "${TINYSTASH_DIR}/resty_modules/lualib/?.so;;"; 60 | init_worker_by_lua_block { 61 | _G.OPENRESTY_PREFIX = [===[${OPENRESTY_PREFIX}]===] 62 | local TINYSTASH_DIR = [===[${TINYSTASH_DIR}]===] 63 | _G.TINYSTASH_DIR = TINYSTASH_DIR 64 | _G.print = function(...) 65 | io.stdout:write(table.concat({...})) 66 | io.stdout:write('\n') 67 | io.stdout:flush() 68 | end 69 | local orig_error = error 70 | _G.error = function(msg, lvl) 71 | orig_error(msg, lvl or 0) 72 | end 73 | _G.run_command = function(command_name, argv) 74 | local chunk, err = loadfile([===[${COMMANDS_DIR}]===] .. '/' .. command_name .. '.lua') 75 | if not chunk then 76 | return false, err 77 | end 78 | local ok, err = pcall(chunk, argv) 79 | if not ok then 80 | return false, err 81 | end 82 | return true 83 | end 84 | require('ngx.process').signal_graceful_exit() 85 | ngx.timer.at(0, function() 86 | local command_name = [===[${command_name}]===] 87 | $(make_argv "${@}") 88 | local ok, err = run_command(command_name, argv) 89 | if not ok then 90 | print(err) 91 | os.exit(1) 92 | end 93 | end) 94 | } 95 | } 96 | __EOF__ 97 | 98 | "${OPENRESTY_PREFIX}/nginx/sbin/nginx" -c "${nginx_conf}" -p "${nginx_root}" 99 | -------------------------------------------------------------------------------- /app/views/init.lua: -------------------------------------------------------------------------------- 1 | local error = require('app.utils').error 2 | local render_to_string = require('app.views.helpers').render_to_string 3 | 4 | 5 | local ngx_print = ngx.print 6 | local ngx_header = ngx.header 7 | local ngx_req = ngx.req 8 | local ngx_HTTP_NOT_ALLOWED = ngx.HTTP_NOT_ALLOWED 9 | 10 | 11 | local template_handler_meta = { 12 | __call = function(self) 13 | ngx_header['content-type'] = self.content_type 14 | if self.content then 15 | ngx_print(self.content) 16 | return 17 | end 18 | local content = render_to_string(self.template, self.context) 19 | if self.cache then 20 | self.content = content 21 | end 22 | ngx_print(content) 23 | end 24 | } 25 | 26 | 27 | local template_handler = function(params) 28 | -- params table: 29 | -- [1] = template path 30 | -- [2] = context (optional) 31 | -- content_type = 32 | -- cache = true 33 | local cache = params.cache 34 | if cache == nil then 35 | cache = true 36 | end 37 | local content_type = params.content_type 38 | if not content_type then 39 | content_type = 'text/html' 40 | end 41 | return setmetatable({ 42 | template = params[1], 43 | context = params[2], 44 | content_type = content_type, 45 | cache = cache, 46 | }, template_handler_meta) 47 | end 48 | 49 | 50 | local view_meta = { 51 | __call = function(self, ...) 52 | local args 53 | if self.initial then 54 | args = {self.initial(...)} 55 | else 56 | args = {...} 57 | end 58 | local method = ngx_req.get_method() 59 | local handler = self[method] 60 | if not handler then 61 | return error(ngx_HTTP_NOT_ALLOWED) 62 | else 63 | handler(unpack(args)) 64 | if not ngx_header['content-type'] then 65 | ngx_header['content-type'] = 'text/html' 66 | end 67 | end 68 | end 69 | } 70 | 71 | 72 | local view = function(handlers) 73 | if type(handlers) == 'string' then 74 | handlers = require(handlers) 75 | end 76 | local view_table = {} 77 | for method, handler in pairs(handlers) do 78 | local handler_type = type(handler) 79 | if method == 'initial' then 80 | assert(handler_type == 'function') 81 | view_table.initial = handler 82 | else 83 | if handler_type == 'table' then 84 | handler = template_handler(handler) 85 | elseif handler_type == 'string' then 86 | handler = template_handler({handler}) 87 | end 88 | view_table[method] = handler 89 | end 90 | end 91 | return setmetatable(view_table, view_meta) 92 | end 93 | 94 | 95 | return { 96 | main = view('app.views.main'), 97 | get_file = view('app.views.get-file'), 98 | webhook = view('app.views.webhook'), 99 | upload = view('app.views.upload'), 100 | docs_api = view('app.views.docs-api'), 101 | donate = view('app.views.donate'), 102 | error = require('app.views.error').error_handler, 103 | } 104 | -------------------------------------------------------------------------------- /app/mediatypes.lua: -------------------------------------------------------------------------------- 1 | local DEFAULT_TYPE_ID = 0 2 | local DEFAULT_TYPE = 'application/octet-stream' 3 | 4 | -- KEEP THIS ARRAY DENSE! 5 | -- format: [media type id] = {'correct media type', ...aliases..., extension} 6 | -- TODO: add extension aliases, e.g., oga -> ogg 7 | local MEDIA_TYPES = { 8 | -- audio types 9 | [1] = {'audio/mpeg', 'audio/mp3', 'mp3'}, 10 | [2] = {'audio/mp4', 'm4a'}, 11 | [3] = {'audio/flac', 'flac'}, 12 | [4] = {'audio/ogg', 'audio/vorbis+ogg', 'audio/opus+ogg', 13 | 'audio/flac+ogg', 'audio/speex+ogg', 'audio/speex', 'ogg'}, 14 | [36] = {'audio/matroska', 'mka'}, 15 | -- image types 16 | [5] = {'image/jpeg', 'jpg'}, 17 | [6] = {'image/png', 'png'}, 18 | [7] = {'image/bmp', 'bmp'}, 19 | [8] = {'image/gif', 'gif'}, 20 | [16] = {'image/webp', 'webp'}, 21 | [19] = {'image/svg+xml', 'svg'}, 22 | [24] = {'image/jxl', 'jxl'}, 23 | [25] = {'image/jxr', 'jxr'}, 24 | -- application types 25 | [9] = {'application/pdf', 'pdf'}, 26 | [10] = {'application/xml', 'xml'}, 27 | [14] = {'application/javascript', 'js'}, 28 | [15] = {'application/json', 'json'}, 29 | [20] = {'application/zip', 'application/x-zip-compressed', 'zip'}, 30 | [21] = {'application/gzip', 'gz'}, 31 | [26] = {'application/vnd.rar', 'application/x-rar', 'rar'}, 32 | [27] = {'application/x-7z-compressed', '7z'}, 33 | [28] = {'application/x-bzip2', 'bz2'}, 34 | [29] = {'application/x-tar', 'tar'}, 35 | [30] = {'application/yaml', 'yaml'}, 36 | [31] = {'application/toml', 'toml'}, 37 | -- text types 38 | [11] = {'text/plain', 'txt'}, 39 | [12] = {'text/html', 'html'}, 40 | [13] = {'text/xml', 'xml'}, 41 | [32] = {'text/markdown', 'md'}, 42 | [33] = {'text/x-shellscript', 'application/x-shellscript', 'sh'}, 43 | [34] = {'text/x-python', 'py'}, 44 | [35] = {'text/x-lua', 'lua'}, 45 | -- video types 46 | [17] = {'video/mp4', 'mp4'}, 47 | [18] = {'video/webm', 'webm'}, 48 | [22] = {'video/matroska', 'mkv'}, 49 | [23] = {'video/quicktime', 'mov'}, 50 | [37] = {'video/ogg', 'ogv'}, 51 | [38] = {'video/vnd.avi', 'video/avi', 'video/x-msvideo', 'avi'}, 52 | [39] = {'video/x-flv', 'flv'}, 53 | 54 | -- [40] = {}, -- next index 55 | } 56 | 57 | 58 | local ID_TYPE_MAP = {} 59 | local TYPE_ID_MAP = {} 60 | local TYPE_EXT_MAP = {} 61 | 62 | 63 | for media_type_id = 1, #MEDIA_TYPES do 64 | local media_type_table = MEDIA_TYPES[media_type_id] 65 | local media_type_table_size = #media_type_table 66 | local media_type = media_type_table[1] 67 | local extension = media_type_table[media_type_table_size] 68 | TYPE_ID_MAP[media_type] = media_type_id 69 | ID_TYPE_MAP[media_type_id] = media_type 70 | TYPE_EXT_MAP[media_type] = extension 71 | if media_type_table_size > 2 then 72 | for index = 2, media_type_table_size-1 do 73 | local media_type_alias = media_type_table[index] 74 | TYPE_ID_MAP[media_type_alias] = media_type_id 75 | TYPE_EXT_MAP[media_type_alias] = extension 76 | end 77 | end 78 | end 79 | 80 | 81 | return { 82 | DEFAULT_TYPE_ID = DEFAULT_TYPE_ID, 83 | DEFAULT_TYPE = DEFAULT_TYPE, 84 | ID_TYPE_MAP = ID_TYPE_MAP, 85 | TYPE_ID_MAP = TYPE_ID_MAP, 86 | TYPE_EXT_MAP = TYPE_EXT_MAP, 87 | } 88 | -------------------------------------------------------------------------------- /commands/conf.lua: -------------------------------------------------------------------------------- 1 | local process_file = require('resty.template').new({ 2 | root = TINYSTASH_DIR, 3 | }).process_file 4 | 5 | local config = require('app.config').nginx_conf 6 | 7 | 8 | assert(OPENRESTY_PREFIX and OPENRESTY_PREFIX ~= '', 'OPENRESTY_PREFIX is not set') 9 | 10 | local NUMBER = type(1) 11 | local STRING = type('') 12 | local BOOLEAN = type(true) 13 | local TABLE = type({}) 14 | 15 | local NIL = {} 16 | 17 | local option = function(params) 18 | local name = params[1] 19 | local value = config[name] 20 | if value == nil then 21 | if params.default == nil then 22 | error(("missing required option '%s'"):format(name)) 23 | elseif params.default == NIL then 24 | value = nil 25 | else 26 | value = params.default 27 | end 28 | elseif params.validator then 29 | local err 30 | value, err = params.validator(value) 31 | if err then 32 | error(("option '%s' validation error: %s"):format(name, err)) 33 | end 34 | end 35 | return value 36 | end 37 | 38 | local _type_validator = function(valid_types, value) 39 | local value_type = type(value) 40 | for _, valid_type in ipairs(valid_types) do 41 | if value_type == valid_type then 42 | return value 43 | end 44 | end 45 | return nil, ('invalid value type: expected %s, got %s'):format( 46 | table.concat(valid_types, ' or '), value_type) 47 | end 48 | 49 | local type_validator = function(...) 50 | local valid_types = {...} 51 | return function(value) 52 | return _type_validator(valid_types, value) 53 | end 54 | end 55 | 56 | local worker_processes_validator = function(value) 57 | if value == 'auto' or type(value) == NUMBER then 58 | return value 59 | end 60 | return nil, 'must be a number or "auto", got ' .. value 61 | end 62 | 63 | local lua_code_cache_validator = function(value) 64 | if value == 'on' or value == 'off' then 65 | return value 66 | elseif type(value) == BOOLEAN then 67 | return value and 'on' or 'off' 68 | end 69 | return nil, 'must be a boolean or "on" of "off", got ' .. value 70 | end 71 | 72 | local context = { 73 | worker_processes = option{'worker_processes', validator = worker_processes_validator, default = 'auto'}, 74 | worker_connections = option{'worker_connections', validator = type_validator(NUMBER), default = NIL}, 75 | error_log = option{'error_log', validator = type_validator(TABLE), default = {}}, 76 | resolver = option{'resolver', validator = type_validator(STRING)}, 77 | lua_ssl_trusted_certificate = option{'lua_ssl_trusted_certificate', validator = type_validator(STRING)}, 78 | lua_ssl_verify_depth = option{'lua_ssl_verify_depth', validator = type_validator(NUMBER)}, 79 | resty_http_debug_logging = option{'resty_http_debug_logging', validator = type_validator(BOOLEAN), default = false}, 80 | listen = option{'listen', validator = type_validator(NUMBER)}, 81 | access_log = option{'access_log', validator = type_validator(STRING), default = 'off'}, 82 | lua_code_cache = option{'lua_code_cache', validator = lua_code_cache_validator, default = true}, 83 | client_max_body_size = option{'client_max_body_size', validator = type_validator(STRING, NUMBER)}, 84 | } 85 | print(process_file('nginx.conf.tpl', context)) 86 | -------------------------------------------------------------------------------- /nginx.conf.tpl: -------------------------------------------------------------------------------- 1 | daemon off; 2 | worker_processes {* worker_processes *}; 3 | pid nginx.pid; 4 | pcre_jit on; 5 | 6 | {% for _, line in ipairs(error_log) do %} 7 | error_log {* line *}; 8 | {% end %} 9 | 10 | events { 11 | {% if worker_connections then %} 12 | worker_connections {* worker_connections *}; 13 | {% end %} 14 | } 15 | 16 | env TINYSTASH_CONFIG_PATH; 17 | 18 | http { 19 | server_tokens off; 20 | sendfile on; 21 | absolute_redirect off; 22 | 23 | resolver {* resolver *}; 24 | lua_ssl_trusted_certificate {* lua_ssl_trusted_certificate *}; 25 | lua_ssl_verify_depth {* lua_ssl_verify_depth *}; 26 | 27 | lua_package_path "$prefix/resty_modules/lualib/?.lua;$prefix/resty_modules/lualib/?/init.lua;$prefix/?.lua;$prefix/?/init.lua;;"; 28 | lua_package_cpath "$prefix/resty_modules/lualib/?.so;;"; 29 | 30 | init_by_lua_block { 31 | {% if resty_http_debug_logging then %} 32 | require('resty.http').debug(true) 33 | {% end %} 34 | collectgarbage('collect') 35 | } 36 | 37 | server { 38 | listen {* listen *}; 39 | access_log {* access_log *}; 40 | lua_code_cache {* lua_code_cache *}; 41 | default_type text/html; 42 | client_max_body_size 8k; 43 | error_page 400 403 404 405 411 413 500 501 502 /error; 44 | 45 | location /static/ { 46 | alias ./static/; 47 | try_files $uri =404; 48 | include {* OPENRESTY_PREFIX *}/nginx/conf/mime.types; 49 | } 50 | 51 | location = /favicon.ico { 52 | alias ./static/favicon.ico; 53 | default_type image/vnd.microsoft.icon; 54 | } 55 | 56 | location = / { 57 | content_by_lua_block { 58 | require('app.views').main() 59 | } 60 | } 61 | 62 | location ~ ^/donate/?$ { 63 | content_by_lua_block { 64 | require('app.views').donate() 65 | } 66 | } 67 | 68 | location ~ ^/docs/api/?$ { 69 | content_by_lua_block { 70 | require('app.views').docs_api() 71 | } 72 | } 73 | 74 | location ~ ^/(?Pdl|il|ln)/(?P[a-zA-Z0-9]+)(?:\.[a-zA-Z0-9_.]+|/(?P[^\\/]+))?/?$ { 75 | content_by_lua_block { 76 | require('app.views').get_file(ngx.var.tiny_id, ngx.var.mode, ngx.var.file_name) 77 | } 78 | } 79 | 80 | location ~ ^/webhook/(?P[a-zA-Z0-9:_-]+)/?$ { 81 | client_max_body_size 256k; 82 | content_by_lua_block { 83 | require('app.views').webhook(ngx.var.secret) 84 | } 85 | } 86 | 87 | location ~ ^/upload/?$ { 88 | return 308 /upload/file; 89 | } 90 | 91 | location ~ ^/upload/(?Pfile|text)/?$ { 92 | client_max_body_size {* client_max_body_size *}; 93 | content_by_lua_block { 94 | require('app.views').upload(ngx.var.type) 95 | } 96 | } 97 | 98 | location ~ ^/upload/url/?$ { 99 | client_max_body_size 8k; 100 | content_by_lua_block { 101 | require('app.views').upload('url') 102 | } 103 | } 104 | 105 | location / { 106 | return 404; 107 | } 108 | 109 | location /error { 110 | internal; 111 | content_by_lua_block { 112 | require('app.views').error() 113 | } 114 | } 115 | 116 | header_filter_by_lua_block { 117 | require('app.phases.header_filter').deny_page_framing() 118 | } 119 | 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /config.example.lua: -------------------------------------------------------------------------------- 1 | return { 2 | 3 | aes = { 4 | key = 'change_me', 5 | -- salt can be either nil or exactly 8 characters long 6 | salt = 'changeme', 7 | size = 256, 8 | mode = 'cbc', 9 | hash = 'sha512', 10 | hash_rounds = 5, 11 | }, 12 | 13 | tg = { 14 | bot_username = 'tinystash_bot', 15 | -- bot authorization token 16 | token = 'bot_token', 17 | -- tg server request timeout, seconds 18 | request_timeout = 10, 19 | -- secret part of the webhook url: https://www.example.com/webhook/ 20 | -- set to nil to use the authorization token as a secret 21 | -- see also: https://core.telegram.org/bots/api#setwebhook 22 | webhook_secret = nil, 23 | -- chat_id (integer or string) for uploaded files 24 | -- set to nil to disable http uploading 25 | upload_chat_id = nil, 26 | -- chat_id (integer or string) for forwarded messages 27 | -- set to nil to disable message forwarding 28 | forward_chat_id = nil, 29 | }, 30 | 31 | nginx_conf = { 32 | -- (int, required) 33 | listen = 80, 34 | -- (int | 'auto', optional, default is 'auto') 35 | worker_processes = 'auto', 36 | -- (int, optional) 37 | worker_connections = nil, 38 | -- (string, required) 20 MiB getFile API method limit + 10% (multipart/form-data overhead) 39 | client_max_body_size = '22M', 40 | -- (array of strings, optional) 41 | error_log = { 42 | -- log everything to stderr 43 | 'stderr debug', 44 | -- uncomment the next line to log error messages to a file 45 | -- 'logs/error.log error', 46 | }, 47 | -- (string, optional, default is 'off') access log is disabled, set file path to enable 48 | access_log = 'off', 49 | -- (string, required) 50 | resolver = '8.8.8.8 ipv6=off', 51 | -- (boolean | 'on' | 'off', optional, default is true/'on') set to false/'off' in development mode 52 | lua_code_cache = true, 53 | -- (string, required) 54 | lua_ssl_trusted_certificate = '/etc/ssl/certs/ca-certificates.crt', 55 | -- (int, required) 56 | lua_ssl_verify_depth = 5, 57 | -- (boolean, optional, default is false) 58 | resty_http_debug_logging = false, 59 | }, 60 | 61 | -- url prefix for generated links: scheme://host[:port][/path], e.g., 62 | -- https://example.com/ --> https://example.com/ln/ 63 | -- https://example.com/tiny --> https://example.com/tiny/ln/ 64 | -- https://example.com/stash/ --> https://example.com/stash/ln/ 65 | -- trailing slashes are ignored 66 | link_url_prefix = 'https://example.com/', 67 | -- don't show download links in bot response if content-type is image/* 68 | hide_image_download_link = false, 69 | -- enable direct file upload (e.g., via curl) 70 | enable_upload_api = true, 71 | 72 | donate = { 73 | -- enable donate page 74 | enable = false, 75 | -- the intro text displayed before blocks, html-formatted 76 | -- set to nil to disable 77 | intro = '

Please donate

', 78 | -- an array of blocks; each block may contain text, text+link, or html 79 | blocks = { 80 | { 81 | text = 'this is a line of text', 82 | }, 83 | { 84 | text = 'this is a link to example.com/donate', 85 | link = 'https://example.com/donate', 86 | }, 87 | { 88 | html = 'this is an html block', 89 | }, 90 | }, 91 | }, 92 | 93 | } 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ᵗⁱⁿʸ[stash] 2 | 3 | [![version](https://img.shields.io/github/tag/un-def/tinystash.svg?maxAge=3600&style=flat-square&label=version)](https://github.com/un-def/tinystash/releases) 4 | [![license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/un-def/tinystash/blob/master/LICENSE) 5 | [![Docker pulls](https://img.shields.io/docker/pulls/un1def/tinystash.svg?maxAge=3600&style=flat-square)](https://hub.docker.com/r/un1def/tinystash/) 6 | 7 | A storage-less database-less file sharing service. 8 | 9 | Written in [Lua][lua]. Powered by [LuaJIT][luajit] and [OpenResty][openresty]. 10 | 11 | 12 | ## Introduction 13 | 14 | **tiny[stash]** is a [website][tinystash-site] and a [Telegram bot][tinystash-bot] for file sharing. A key feature of the service is the fact that it does not actually store anything — all files are stored on Telegram file servers and proxied by the service. You might be familiar with this idea — there is a plenty of file sharing services that work this way. But **tiny[stash]** is going even further. In addition, it does not use any kind of database, all required information is encoded, encrypted and stored directly in a URL. 15 | 16 | It is also undemanding in terms of machine resources. If it is possible to run the **nginx** server on some hardware, it'll probably be possible to run **tiny[stash]** too. It also does not require a lot of RAM or disk storage because all data are processed in a streaming fashion. 17 | 18 | 19 | ## Installing 20 | 21 | ### OpenResty 22 | 23 | See [instructions][openresty-installation] on [OpenResty website][openresty]. 24 | 25 | ### Lua packages 26 | 27 | ```shell 28 | $ opm --cwd get $(cat requirements.opm) 29 | ``` 30 | 31 | 32 | ## Configuring 33 | 34 | ```shell 35 | $ cp config.example.lua config.lua 36 | $ vi config.lua 37 | ``` 38 | 39 | 40 | ## Setting up Telegram bot webhook 41 | 42 | ```shell 43 | $ ./tinysta.sh webhook set 44 | ``` 45 | 46 | 47 | ## Running 48 | 49 | ```shell 50 | $ ./tinysta.sh run 51 | ``` 52 | 53 | 54 | ## Quick deployment with Docker 55 | 56 | 1. Prepare `config.lua` as described above. 57 | 58 | 2. Set up Telegram bot webhook : 59 | ```shell 60 | $ docker run --rm -it \ 61 | -v /path/to/config.lua:/opt/tinystash/config.lua \ 62 | un1def/tinystash webhook set 63 | ``` 64 | 65 | 3. Run Docker container: 66 | ```shell 67 | $ docker run -d \ 68 | --restart unless-stopped \ 69 | -v /path/to/config.lua:/opt/tinystash/config.lua \ 70 | -p 80:80 \ 71 | --name tinystash \ 72 | un1def/tinystash 73 | ``` 74 | 75 | 76 | ## License 77 | 78 | Source code is licensed under the [MIT License][license]. 79 | 80 | Source Sans Pro font is licensed under the [SIL Open Font License, Version 1.1][license-font-sourcesanspro]. 81 | 82 | Source Code Pro font is licensed under the [SIL Open Font License, Version 1.1][license-font-sourcecodepro]. 83 | 84 | 85 | 86 | [telegram]: http://telegram.org/ 87 | [lua]: https://lua.org/ 88 | [luajit]: https://luajit.org/ 89 | [openresty]: https://openresty.org/ 90 | [openresty-installation]: https://openresty.org/en/installation.html 91 | [tinystash-site]: https://tinystash.undef.im/ 92 | [tinystash-bot]: https://t.me/tinystash_bot 93 | [license]: https://github.com/un-def/tinystash/blob/master/LICENSE 94 | [license-font-sourcesanspro]: https://github.com/un-def/tinystash/blob/master/static/OFL-SourceSansPro.txt 95 | [license-font-sourcecodepro]: https://github.com/un-def/tinystash/blob/master/static/OFL-SourceCodePro.txt 96 | -------------------------------------------------------------------------------- /app/uploader/url/direct.lua: -------------------------------------------------------------------------------- 1 | local json_decode =require('cjson.safe').decode 2 | local parse_header = require('httoolsp.headers').parse_header 3 | 4 | local base = require('app.uploader.base') 5 | local url_mixin = require('app.uploader.url.mixin') 6 | local utils = require('app.utils') 7 | 8 | local string_format = string.format 9 | local ngx_ERR = ngx.ERR 10 | local ngx_HTTP_BAD_REQUEST = ngx.HTTP_BAD_REQUEST 11 | local ngx_req = ngx.req 12 | local ngx_var = ngx.var 13 | 14 | local is_http_url = utils.is_http_url 15 | local log = utils.log 16 | local wrap_error = utils.wrap_error 17 | 18 | 19 | local HTTP_UNSUPPORTED_MEDIA_TYPE = 415 20 | 21 | local uploader = base.build_uploader(url_mixin, base.uploader) 22 | 23 | uploader.new = function(_self, _upload_type, chat_id, _headers) 24 | local content_type = ngx_var.http_content_type 25 | if not content_type then 26 | log('no content-type') 27 | return nil, HTTP_UNSUPPORTED_MEDIA_TYPE 28 | end 29 | content_type = parse_header(content_type) 30 | local is_form = false 31 | local is_json = false 32 | local is_plain = false 33 | if content_type == 'application/x-www-form-urlencoded' then 34 | is_form = true 35 | elseif content_type == 'application/json' then 36 | is_json = true 37 | elseif content_type == 'text/plain' then 38 | is_plain = true 39 | else 40 | log('unsupported content type: %s', content_type) 41 | return nil, HTTP_UNSUPPORTED_MEDIA_TYPE 42 | end 43 | return setmetatable({ 44 | chat_id = chat_id, 45 | is_form = is_form, 46 | is_json = is_json, 47 | is_plain = is_plain, 48 | }, uploader) 49 | end 50 | 51 | uploader.run = function(self) 52 | -- sets: 53 | -- self.client: tg.client (via upload) 54 | -- self.media_type: string (via upload) 55 | -- self.bytes_uploaded: int (via upload) 56 | ngx_req.read_body() 57 | local url, err 58 | if self.is_form then 59 | url, err = self:parse_form() 60 | else 61 | local body = ngx_req.get_body_data() 62 | if not body then 63 | err = 'no body' 64 | elseif self.is_json then 65 | url, err = self:parse_json(body) 66 | elseif self.is_plain then 67 | url = body 68 | else 69 | log(ngx_ERR, 'should not reach here') 70 | end 71 | end 72 | if not url then 73 | log(err) 74 | return nil, ngx_HTTP_BAD_REQUEST 75 | end 76 | log('url: %s', url) 77 | if not is_http_url(url) then 78 | log('invalid url') 79 | return nil, ngx_HTTP_BAD_REQUEST 80 | end 81 | return self:upload(url) 82 | end 83 | 84 | uploader.parse_form = function(_self) 85 | local args, err = ngx_req.get_post_args() 86 | if err then 87 | return nil, err 88 | end 89 | local url = args.url 90 | if url == nil then 91 | return nil, 'no url field' 92 | end 93 | if type(url) == 'table' then 94 | return nil, 'multiple url fields' 95 | end 96 | return url 97 | end 98 | 99 | uploader.parse_json = function(_self, body) 100 | local json, err = json_decode(body) 101 | if not json then 102 | return nil, wrap_error('json decode error', err) 103 | end 104 | if type(json) ~= 'table' then 105 | return nil, string_format('json object expected, got: %s', type(json)) 106 | end 107 | local url = json.url 108 | if url == nil then 109 | return nil, 'no url field' 110 | end 111 | if type(url) ~= 'string' then 112 | return nil, string_format('json string expected, got: %s', type(url)) 113 | end 114 | return url 115 | end 116 | 117 | return uploader 118 | -------------------------------------------------------------------------------- /templates/web/docs-api.html: -------------------------------------------------------------------------------- 1 | {% local layout = 'web/layout.html' %} 2 |
3 | 4 |

Direct Upload API

5 | 6 |

tiny[stash] Direct Upload API allows you to upload files using various HTTP libraries for programming languages or HTTP clients such as curl.

7 | 8 | 9 | 10 |

Examples

11 | 12 |
# Upload video (.mp4) file 13 | curl {* link_url_prefix *}/upload/file -H App-ID:example -H Content-Type:video/mp4 --data-binary @/path/to/video.mp4
14 | 15 |
# Upload text from pipe, get JSON response 16 | echo 'This is my first text upload' | curl {* link_url_prefix *}/upload/text -H App-ID:example -H Accept:application/json --data-binary @-
17 | 18 |
# Upload image via URL 19 | curl {* link_url_prefix *}/upload/url -H App-ID:example -d url=https://www.gnu.org/graphics/gnu-head.png
20 | 21 |

Endpoints

22 | 23 |
    24 |
  • File uploads: {* link_url_prefix *}/upload/file
  • 25 |
  • Text uploads: {* link_url_prefix *}/upload/text
  • 26 |
  • URL uploads: {* link_url_prefix *}/upload/url
  • 27 |
28 | 29 | 30 | 31 |

Common Headers

32 | 33 |

The following headers are used by all endpoints.

34 | 35 |
    36 |
  • App-ID Required. An arbitrary string.
  • 37 |
  • Accept Optional. Set to application/json to get a JSON response (plain text will be used otherwise).

    38 |
39 | 40 | 41 | 42 |

File Upload

43 | 44 |

Endpoint

45 | 46 |

{* link_url_prefix *}/upload/file

47 | 48 |

Headers

49 | 50 |
    51 |
  • Content-Length Required. The size of a body in bytes. {( includes/max_file_size.txt )}
  • 52 |
  • Content-Type Optional. The default value is application/octet-stream. 53 |
54 | 55 |

Body

56 | 57 |

Raw file content. Compressed or chunked transfer encodings are not supported.

58 | 59 | 60 | 61 |

Text Upload

62 | 63 |

Endpoint

64 | 65 |

{* link_url_prefix *}/upload/text

66 | 67 |

Headers

68 | 69 |
    70 |
  • Content-Length Required. The size of a body in bytes. {( includes/max_file_size.txt )}
  • 71 |
72 | 73 |

Body

74 | 75 |

Text content. Compressed or chunked transfer encodings are not supported.

76 | 77 | 78 | 79 |

URL Upload

80 | 81 |

Endpoint

82 | 83 |

{* link_url_prefix *}/upload/url

84 | 85 |

Headers

86 | 87 |
    88 |
  • Content-Type Required. One of: 89 |
      90 |
    • application/json
    • 91 |
    • application/x-www-form-urlencoded
    • 92 |
    • text/plain
    • 93 |
    94 |
95 | 96 |

Body

97 | 98 |
    99 |
  • JSON: {"url": "<URL>"}
  • 100 |
  • URL-encoded form: url=<URL>
  • 101 |
  • Plain text: <URL>
  • 102 |
103 | 104 |

{( includes/max_file_size.txt )}

105 | 106 |
107 | -------------------------------------------------------------------------------- /commands/webhook.lua: -------------------------------------------------------------------------------- 1 | local http = require('resty.http') 2 | local json = require('cjson.safe') 3 | 4 | local config = require('app.config') 5 | local token = config.tg.token 6 | local secret = config._processed.tg_webhook_secret 7 | 8 | 9 | local assume_yes = false 10 | local verbose = false 11 | 12 | if not token then 13 | error('Bad config: set tg.token') 14 | end 15 | 16 | local verbose_print = function(...) 17 | if verbose then 18 | print(...) 19 | end 20 | end 21 | 22 | local show_help = function() 23 | print([[Usage: 24 | webhook get get current webhook status 25 | webhook set set webhook to /webhook/ 26 | webhook set PREFIX set webhook to PREFIX/ 27 | webhook delete delete webhook 28 | webhook -h show this help 29 | 30 | Options: 31 | -v verbose mode 32 | -y assume yes 33 | 34 | 'link_url_prefix' config parameter 35 | one of 'tg' table config parameters: 36 | - 'webhook_secret' (if any) 37 | - 'token' if 'webhook_secret' not set 38 | ]]) 39 | end 40 | 41 | local argv = ... 42 | local arguments = {} 43 | for _, argument in ipairs(argv) do 44 | if argument:sub(1, 1) == '-' then 45 | if argument == '-h' then 46 | show_help() 47 | return 48 | elseif argument == '-y' then 49 | assume_yes = true 50 | elseif argument == '-v' then 51 | verbose = true 52 | else 53 | error('Invalid option: ', argument) 54 | end 55 | else 56 | table.insert(arguments, argument) 57 | end 58 | end 59 | 60 | local ask = function() 61 | if assume_yes then 62 | return 63 | end 64 | io.stdout:write('Are you sure (yes/no)? ') 65 | io.stdout:flush() 66 | if io.stdin:read() ~= 'yes' then 67 | error('Abort') 68 | end 69 | end 70 | 71 | local api_call = function(method, params) 72 | local httpc, res, err 73 | httpc, err = http.new() 74 | if not httpc then 75 | error('TG API request error: ', err) 76 | end 77 | httpc:set_timeout(10000) 78 | local uri = ('https://api.telegram.org/bot%s/%s'):format(token, method) 79 | res, err = httpc:request_uri(uri, { 80 | query = params, 81 | ssl_verify = false, 82 | }) 83 | if not res then 84 | error('TG API request error: ', err) 85 | end 86 | verbose_print('TG API response status: ', res.status) 87 | verbose_print('TG API response body: ', res.body) 88 | res, err = json.decode(res.body) 89 | if not res then 90 | error('TG API response json decode error: ', err) 91 | end 92 | if not res.ok then 93 | error('TG API response error: ', res.description) 94 | end 95 | print('OK') 96 | if res.description then 97 | print(res.description) 98 | end 99 | return res 100 | end 101 | 102 | local cmd = arguments[1] 103 | if cmd == 'get' then 104 | local res = api_call('getWebhookInfo') 105 | local webhook_url = res.result.url 106 | if webhook_url and webhook_url ~= '' then 107 | print('Current webhook: ', webhook_url) 108 | else 109 | print('Webhook not set') 110 | end 111 | elseif cmd == 'set' then 112 | local url_prefix = arguments[2] 113 | if not url_prefix then 114 | url_prefix = ('%s/webhook'):format(config._processed.link_url_prefix) 115 | end 116 | local url = ('%s/%s'):format(url_prefix:match('(.-)/*$'), secret) 117 | print('Set webhook to ', url) 118 | ask() 119 | api_call('setWebhook', {url = url}) 120 | elseif cmd == 'delete' then 121 | print('Delele current webhook') 122 | ask() 123 | api_call('deleteWebhook') 124 | else 125 | show_help() 126 | end 127 | -------------------------------------------------------------------------------- /app/uploader/file/direct.lua: -------------------------------------------------------------------------------- 1 | local constants = require('app.constants') 2 | local utils = require('app.utils') 3 | local base = require('app.uploader.base') 4 | local file_mixin = require('app.uploader.file.mixin') 5 | 6 | local req_socket = ngx.req.socket 7 | local ngx_HTTP_BAD_REQUEST = ngx.HTTP_BAD_REQUEST 8 | local ngx_ERR = ngx.ERR 9 | 10 | local CHUNK_SIZE = constants.CHUNK_SIZE 11 | local DOWNSTREAM_TIMEOUT = constants.DOWNSTREAM_TIMEOUT 12 | 13 | local log = utils.log 14 | local wrap_error = utils.wrap_error 15 | 16 | 17 | local maximum_file_size_err = ( 18 | 'declared content-length is too big - maximum file size is %s' 19 | ):format(base.uploader.MAX_FILE_SIZE) 20 | 21 | local uploader = base.build_uploader(file_mixin, base.uploader) 22 | 23 | uploader.new = function(_, upload_type, chat_id, headers) 24 | -- sets: 25 | -- self.media_type: string 26 | local transfer_encoding = headers['transfer-encoding'] 27 | if transfer_encoding ~= nil then 28 | -- nginx will terminate a request early with 501 Not Implemented 29 | -- if transfer-encoding is not supported or if the client sends 30 | -- invalid chunks (for chunked encoding), therefore, 31 | -- this check is never actually performed 32 | return nil, 501, transfer_encoding .. ' is not supported' 33 | end 34 | local content_length = headers['content-length'] 35 | if not content_length then 36 | return nil, 411, 'no content-length header' 37 | end 38 | content_length = tonumber(content_length) 39 | if not content_length then 40 | return nil, ngx_HTTP_BAD_REQUEST, 'invalid content-length header' 41 | elseif content_length <= 0 then 42 | return nil, ngx_HTTP_BAD_REQUEST, 'content-length header value is 0' 43 | elseif base.uploader:is_max_file_size_exceeded(content_length) then 44 | return nil, 413, maximum_file_size_err 45 | end 46 | local instance = setmetatable({ 47 | upload_type = upload_type, 48 | chat_id = chat_id, 49 | expected_content_length = content_length, 50 | }, uploader) 51 | instance:set_media_type(headers['content-type']) 52 | instance:set_filename(instance.media_type) 53 | return instance 54 | end 55 | 56 | uploader.run = function(self) 57 | -- sets: 58 | -- self.bytes_uploaded: int (via upload) 59 | -- self.client: tg.client (via upload) 60 | local sock, err = req_socket() 61 | if not sock then 62 | log(ngx_ERR, 'downstream socket error: %s', err) 63 | return nil, ngx_HTTP_BAD_REQUEST 64 | end 65 | sock:settimeout(DOWNSTREAM_TIMEOUT) 66 | self.request_socket = sock 67 | local content_iterator = self:get_content_iterator() 68 | local file_object, err_code 69 | file_object, err_code = self:upload(content_iterator) 70 | if not file_object then 71 | return nil, err_code 72 | end 73 | if self.bytes_uploaded ~= self.expected_content_length then 74 | err = ('incorrect content-length - declared %s, uploaded %s'):format( 75 | self.expected_content_length, self.bytes_uploaded) 76 | return nil, ngx_HTTP_BAD_REQUEST, err 77 | end 78 | return file_object 79 | end 80 | 81 | uploader.get_content_iterator = function(self) 82 | local sock = self.request_socket 83 | local done = false 84 | return function() 85 | if done then 86 | return nil 87 | end 88 | -- err: 89 | -- 'closed' -- downstream connection closed (it's maybe ok or not) 90 | -- 'timeout' -- downstream read timeout (it's definitely not ok) 91 | -- we do not check err and finish uploading to telegram anyway 92 | -- because we will compare actual bytes_uploaded with 93 | -- declared expected_content_length later 94 | local data, err, partial = sock:receive(CHUNK_SIZE) 95 | if data then 96 | return data 97 | elseif partial == '' then 98 | return nil 99 | elseif partial then 100 | done = true 101 | return partial 102 | else 103 | return nil, wrap_error('socket error', err) 104 | end 105 | end 106 | end 107 | 108 | return uploader 109 | -------------------------------------------------------------------------------- /app/uploader/base.lua: -------------------------------------------------------------------------------- 1 | local utils = require('app.utils') 2 | local constants = require('app.constants') 3 | local DEFAULT_TYPE = require('app.mediatypes').DEFAULT_TYPE 4 | 5 | 6 | local CSRFTOKEN_FIELD_NAME = constants.CSRFTOKEN_FIELD_NAME 7 | 8 | local log = utils.log 9 | local guess_extension = utils.guess_extension 10 | local escape_ext = utils.escape_ext 11 | local generate_random_hex_string = utils.generate_random_hex_string 12 | 13 | 14 | local not_implemented = function() 15 | error('not implemented') 16 | end 17 | 18 | 19 | local uploader = {} 20 | 21 | uploader.MAX_FILE_SIZE = constants.TG_MAX_FILE_SIZE 22 | 23 | uploader.new = function() 24 | -- params: 25 | -- upload_type: string -- 'file' | 'text' | 'url' 26 | -- chat_id: integer 27 | -- headers: table -- request headers 28 | -- returns: 29 | -- if ok: table -- uploader instance 30 | -- if error: nil, error_code, error_text? 31 | -- sets: 32 | -- ... 33 | -- note: 34 | -- error_text will be sent in a http response, 35 | -- do not expose sensitive data/errors! 36 | not_implemented() 37 | end 38 | 39 | uploader.run = function() 40 | -- params: 41 | -- none 42 | -- returns: 43 | -- if ok: TG API object (Document/Video/...) table with mandatory 'file_id' field 44 | -- if error: nil, error_code, error_text? 45 | -- sets: 46 | -- self.media_type: string 47 | -- self.bytes_uploaded: integer 48 | -- ... 49 | -- error_text will be sent in a http response, 50 | -- do not expose sensitive data/errors! 51 | not_implemented() 52 | end 53 | 54 | uploader.close = function(self) 55 | local client = self.client 56 | if client then 57 | client:close() 58 | self.client = nil 59 | end 60 | end 61 | 62 | uploader.is_max_file_size_exceeded = function(self, file_size) 63 | return file_size > self.MAX_FILE_SIZE 64 | end 65 | 66 | uploader.set_media_type = function(self, media_type) 67 | -- params: 68 | -- media_type: string or nil 69 | -- sets: 70 | -- self.media_type 71 | if self.upload_type == 'text' then 72 | media_type = 'text/plain' 73 | elseif not media_type then 74 | media_type = DEFAULT_TYPE 75 | end 76 | log('content media type: %s', media_type) 77 | self.media_type = media_type 78 | end 79 | 80 | uploader.set_filename = function(self, media_type, filename) 81 | -- params: 82 | -- media_type: string 83 | -- filename: string or nil 84 | -- sets: 85 | -- self.filename 86 | if not filename then 87 | local ext = guess_extension{media_type = media_type, exclude_dot = true} or 'bin' 88 | filename = ('%s-%s.%s'):format( 89 | media_type:gsub('/', '_'), generate_random_hex_string(16), ext) 90 | log('generated filename: %s', filename) 91 | else 92 | log('original filename: %s', filename) 93 | filename = filename:gsub('[%s";\\]', '_') 94 | end 95 | self.filename = escape_ext(filename) 96 | end 97 | 98 | local form_mixin = {} 99 | 100 | form_mixin.extract_csrftoken_from_cookies = function(_, headers) 101 | local cookie = headers['cookie'] 102 | if not cookie then 103 | return nil, 'no cookie header' 104 | end 105 | for key, value in cookie:gmatch('([^%c%s;]+)=([^%c%s;]+)') do 106 | if key == CSRFTOKEN_FIELD_NAME then 107 | return value 108 | end 109 | end 110 | return nil, 'no csrftoken cookie' 111 | end 112 | 113 | form_mixin.check_csrftoken = function(self, csrftoken, expected) 114 | if expected == nil then 115 | expected = self.csrftoken 116 | end 117 | if csrftoken == nil then 118 | return false, 'no csrf token' 119 | elseif type(csrftoken) == 'table' then 120 | return false, 'multiple csrf token fields' 121 | elseif csrftoken ~= expected then 122 | return false, 'invalid csrf token' 123 | end 124 | return true 125 | end 126 | 127 | local build_uploader = function(...) 128 | local bases = {...} 129 | local uploader_mt = {} 130 | uploader_mt.__index = uploader_mt 131 | for idx = #bases, 1, -1 do 132 | for key, value in pairs(bases[idx]) do 133 | uploader_mt[key] = value 134 | end 135 | end 136 | return uploader_mt 137 | end 138 | 139 | return { 140 | build_uploader = build_uploader, 141 | form_mixin = form_mixin, 142 | uploader = uploader, 143 | } 144 | -------------------------------------------------------------------------------- /app/uploader/file/mixin.lua: -------------------------------------------------------------------------------- 1 | local formdata = require('httoolsp.formdata') 2 | 3 | local tg = require('app.tg') 4 | local utils = require('app.utils') 5 | local DEFAULT_TYPE = require('app.mediatypes').DEFAULT_TYPE 6 | 7 | 8 | local ngx_HTTP_BAD_REQUEST = ngx.HTTP_BAD_REQUEST 9 | local ngx_HTTP_BAD_GATEWAY = ngx.HTTP_BAD_GATEWAY 10 | local ngx_ERR = ngx.ERR 11 | local ngx_INFO = ngx.INFO 12 | 13 | local get_file_from_message = tg.get_file_from_message 14 | local tg_client = tg.client 15 | 16 | local log = utils.log 17 | 18 | 19 | local mixin = {} 20 | 21 | mixin.upload = function(self, content) 22 | -- params: 23 | -- content: string or function -- body or iterator producing content chunks 24 | -- returns: 25 | -- if ok: TG API object (Document/Video/...) table with mandatory 'file_id' field 26 | -- if error: nil, error_code 27 | -- sets: 28 | -- self.client: tg.client 29 | -- self.bytes_uploaded: int (via _get_content_iterator closure) 30 | local client = tg_client() 31 | self.client = client 32 | local media_type = self.media_type 33 | -- avoid automatic gif -> mp4 conversion by tricking Telegram 34 | if media_type == 'image/gif' then 35 | -- replace actual content type with generic one 36 | media_type = DEFAULT_TYPE 37 | end 38 | local fd = formdata.new() 39 | fd:set('chat_id', tostring(self.chat_id)) 40 | fd:set('document', self:_get_content_iterator(content), media_type, self.filename) 41 | local resp, err = client:send_document({ 42 | headers = { 43 | ['content-type'] = 'multipart/form-data; boundary=' .. fd:get_boundary(), 44 | ['transfer-encoding'] = 'chunked', 45 | }, 46 | body = self:_get_chunked_body_iterator(fd:iterator()), 47 | }) 48 | -- _get_content_iterator closure sets self._content_iterator_error to indicate error 49 | -- while reading content from request 50 | if self._content_iterator_error then 51 | return nil, ngx_HTTP_BAD_REQUEST 52 | end 53 | if err then 54 | log(ngx_ERR, 'tg api request error: %s', err) 55 | return nil, ngx_HTTP_BAD_GATEWAY 56 | end 57 | if not resp.ok then 58 | log(ngx_INFO, 'tg api response is not "ok": %s', resp.description) 59 | return nil, ngx_HTTP_BAD_GATEWAY 60 | end 61 | if not resp.result then 62 | log(ngx_INFO, 'tg api response has no "result"') 63 | return nil, ngx_HTTP_BAD_GATEWAY 64 | end 65 | local file 66 | file, err = get_file_from_message(resp.result) 67 | if not file then 68 | log(ngx_INFO, err) 69 | return nil, ngx_HTTP_BAD_GATEWAY 70 | end 71 | local file_object = file.object 72 | if not file_object.file_id then 73 | log(ngx_INFO, 'tg api response has no "file_id"') 74 | return nil, ngx_HTTP_BAD_GATEWAY 75 | end 76 | return file_object 77 | end 78 | 79 | mixin._get_content_iterator = function(self, content) 80 | -- params: 81 | -- content: string or function -- body or iterator producing body chunks 82 | -- returns: 83 | -- iterator function that controls uploaded file size and sets self.bytes_uploaded 84 | if type(content) == 'function' then 85 | return function() 86 | local bytes_uploaded = self.bytes_uploaded or 0 87 | if self:is_max_file_size_exceeded(bytes_uploaded) then 88 | log(ngx_INFO, 'content iterator produced more bytes than MAX_FILE_SIZE, breaking consuming') 89 | return nil 90 | end 91 | local chunk, err = content() 92 | if err then 93 | log(ngx_INFO, 'content iterator error: %s', err) 94 | self._content_iterator_error = err 95 | return nil 96 | end 97 | if not chunk then 98 | log('end of content') 99 | return nil 100 | end 101 | self.bytes_uploaded = bytes_uploaded + #chunk 102 | return chunk 103 | end 104 | else 105 | local done = false 106 | return function() 107 | if done then 108 | self.bytes_uploaded = #content 109 | return nil 110 | end 111 | done = true 112 | return content 113 | end 114 | end 115 | end 116 | 117 | mixin._get_chunked_body_iterator = function(_, iterator) 118 | -- params: 119 | -- iterator: iterator producing body chunks 120 | -- returns: 121 | -- iterator producing `transfer-encoding: chunked` chunks 122 | local done = false 123 | return function() 124 | if done then 125 | return nil 126 | end 127 | local chunk = iterator() 128 | if not chunk then 129 | done = true 130 | chunk = '' 131 | end 132 | return ('%X\r\n%s\r\n'):format(#chunk, chunk) 133 | end 134 | end 135 | 136 | return mixin 137 | -------------------------------------------------------------------------------- /static/OFL-SourceSansPro.txt: -------------------------------------------------------------------------------- 1 | Copyright 2010, 2012, 2014 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name ‘Source’. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /static/OFL-SourceCodePro.txt: -------------------------------------------------------------------------------- 1 | Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /app/uploader/file/form.lua: -------------------------------------------------------------------------------- 1 | local upload = require('resty.upload') 2 | local parse_header = require('httoolsp.headers').parse_header 3 | 4 | local utils = require('app.utils') 5 | local constants = require('app.constants') 6 | local base = require('app.uploader.base') 7 | local file_mixin = require('app.uploader.file.mixin') 8 | 9 | local string_format = string.format 10 | local ngx_null = ngx.null 11 | local ngx_HTTP_FORBIDDEN = ngx.HTTP_FORBIDDEN 12 | local ngx_HTTP_BAD_REQUEST = ngx.HTTP_BAD_REQUEST 13 | 14 | local log = utils.log 15 | local wrap_error = utils.wrap_error 16 | 17 | local CHUNK_SIZE = constants.CHUNK_SIZE 18 | local CSRFTOKEN_FIELD_NAME = constants.CSRFTOKEN_FIELD_NAME 19 | 20 | 21 | local uploader = base.build_uploader(file_mixin, base.form_mixin, base.uploader) 22 | 23 | uploader.new = function(self, upload_type, chat_id, headers) 24 | local csrftoken, err = self:extract_csrftoken_from_cookies(headers) 25 | if not csrftoken then 26 | log(err) 27 | return nil, ngx_HTTP_BAD_REQUEST 28 | end 29 | local form 30 | form, err = upload:new(CHUNK_SIZE) 31 | if not form then 32 | log('resty.upload:new() error: %s', err) 33 | return nil, ngx_HTTP_BAD_REQUEST 34 | end 35 | form:set_timeout(1000) -- 1 sec 36 | return setmetatable({ 37 | upload_type = upload_type, 38 | content_field = upload_type, 39 | chat_id = chat_id, 40 | csrftoken = csrftoken, 41 | form = form, 42 | }, uploader) 43 | end 44 | 45 | uploader.run = function(self) 46 | -- sets: 47 | -- self.media_type: string 48 | -- self.bytes_uploaded: int (via upload) 49 | -- self.client: tg.client (via upload) 50 | local csrftoken, file_object 51 | local ok, res, err, err_code 52 | while true do 53 | res, err = self:handle_form_field() 54 | if not res then 55 | log('handle_form_field() error: %s', err) 56 | return nil, ngx_HTTP_BAD_REQUEST 57 | elseif res == ngx_null then 58 | log('end of form') 59 | break 60 | else 61 | local field_name, filename, media_type, initial_data = unpack(res) 62 | if field_name == CSRFTOKEN_FIELD_NAME then 63 | csrftoken = initial_data 64 | ok, err = self:check_csrftoken(csrftoken) 65 | if not ok then 66 | log(err) 67 | return nil, ngx_HTTP_FORBIDDEN 68 | end 69 | elseif field_name == self.content_field then 70 | if initial_data:len() == 0 then 71 | return nil, ngx_HTTP_BAD_REQUEST, 'empty file' 72 | end 73 | self:set_media_type(media_type) 74 | self:set_filename(self.media_type, filename) 75 | local content_iterator = self:get_content_iterator(initial_data) 76 | file_object, err_code = self:upload(content_iterator) 77 | if not file_object then 78 | return nil, err_code 79 | end 80 | else 81 | log('unexpected form field: %s', filename) 82 | return nil, ngx_HTTP_BAD_REQUEST 83 | end 84 | end 85 | end 86 | ok, err = self:check_csrftoken(csrftoken) 87 | if not ok then 88 | log(err) 89 | return nil, ngx_HTTP_FORBIDDEN 90 | end 91 | if not file_object then 92 | log('no content') 93 | return nil, ngx_HTTP_BAD_REQUEST 94 | end 95 | return file_object 96 | end 97 | 98 | uploader.handle_form_field = function(self) 99 | -- returns: 100 | -- if eof: ngx.null 101 | -- if body (any): table {field_name, filename, media_type, initial_data} 102 | -- if error (any): nil, error_code, error_text? 103 | local form = self.form 104 | local field_name, filename, media_type 105 | local token, data, err 106 | while true do 107 | token, data, err = form:read() 108 | -- 'part_end' tokens are ignored 109 | if not token then 110 | return nil, err 111 | elseif token == 'eof' then 112 | return ngx_null 113 | elseif token == 'header' then 114 | if type(data) ~= 'table' then 115 | return nil, 'invalid form-data part header' 116 | end 117 | local header = data[1]:lower() 118 | local value = data[2] 119 | if header == 'content-type' then 120 | media_type = value 121 | elseif header == 'content-disposition' then 122 | local _, field = parse_header(value) 123 | field_name = field.name 124 | filename = field.filename 125 | end 126 | elseif token == 'body' and field_name then 127 | return {field_name, filename, media_type, data} 128 | end 129 | end 130 | end 131 | 132 | uploader.get_content_iterator = function(self, initial) 133 | local form = self.form 134 | local initial_sent = false 135 | local empty_chunk_seen = false 136 | local iterator 137 | iterator = function() 138 | if not initial_sent then 139 | initial_sent = true 140 | -- first chunk of the file 141 | return initial 142 | end 143 | local token, data, err = form:read() 144 | if not token then 145 | return nil, wrap_error('failed to read next form chunk', err) 146 | elseif token == 'body' then 147 | if #data == 0 then 148 | -- if file size % CHUNK_SIZE == 0, form:read() returns an empty string; 149 | -- we discard this chunk and expect 'part_end' token on the next iteration 150 | if empty_chunk_seen then 151 | return nil, 'two empty chunks in a row, part_end expected' 152 | end 153 | empty_chunk_seen = true 154 | return iterator() 155 | end 156 | empty_chunk_seen = false 157 | return data 158 | elseif token == 'part_end' then 159 | return nil 160 | else 161 | return nil, string_format('unexpected token: %s', token) 162 | end 163 | end 164 | return iterator 165 | end 166 | 167 | return uploader 168 | -------------------------------------------------------------------------------- /app/views/upload.lua: -------------------------------------------------------------------------------- 1 | local json_encode = require('cjson.safe').encode 2 | local parse_accept_header = require('httoolsp.headers').parse_accept_header 3 | 4 | local tinyid = require('app.tinyid') 5 | local utils = require('app.utils') 6 | local constants = require('app.constants') 7 | local helpers = require('app.views.helpers') 8 | local file_form_uploader = require('app.uploader.file.form') 9 | local file_direct_uploader = require('app.uploader.file.direct') 10 | local url_form_uploader = require('app.uploader.url.form') 11 | local url_direct_uploader = require('app.uploader.url.direct') 12 | local config = require('app.config') 13 | 14 | 15 | local ngx_redirect = ngx.redirect 16 | local ngx_print = ngx.print 17 | local ngx_say = ngx.say 18 | local ngx_req = ngx.req 19 | local ngx_req_get_headers = ngx_req.get_headers 20 | local ngx_var = ngx.var 21 | local ngx_header = ngx.header 22 | local ngx_DEBUG = ngx.DEBUG 23 | local ngx_WARN = ngx.WARN 24 | local ngx_ERR = ngx.ERR 25 | local ngx_HTTP_SEE_OTHER = ngx.HTTP_SEE_OTHER 26 | local ngx_HTTP_NOT_FOUND = ngx.HTTP_NOT_FOUND 27 | local ngx_HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR 28 | 29 | local enable_upload = config._processed.enable_upload 30 | local enable_upload_api = config._processed.enable_upload_api 31 | local tg_upload_chat_id = config.tg.upload_chat_id 32 | local url_path_prefix = config._processed.url_path_prefix 33 | 34 | local log = utils.log 35 | local error = utils.error 36 | local generate_random_hex_string = utils.generate_random_hex_string 37 | local get_media_type_id = utils.get_media_type_id 38 | local render_link_factory = helpers.render_link_factory 39 | local render = helpers.render 40 | 41 | local CSRFTOKEN_FIELD_NAME = constants.CSRFTOKEN_FIELD_NAME 42 | 43 | 44 | local err_code_to_log_level = function(err_code) 45 | if err_code >= 500 then 46 | return ngx_ERR 47 | end 48 | return ngx_DEBUG 49 | end 50 | 51 | 52 | local MEDIA_TYPES = { 53 | 'text/plain', 54 | 'application/json', 55 | } 56 | 57 | 58 | return { 59 | 60 | initial = function(upload_type) 61 | if not enable_upload then 62 | return error(ngx_HTTP_NOT_FOUND) 63 | end 64 | return upload_type 65 | end, 66 | 67 | GET = function(upload_type) 68 | -- upload_type: file | text | url 69 | local path = ngx_var.request_uri 70 | local args_idx = path:find('?', 2, true) 71 | if args_idx then 72 | path = path:sub(1, args_idx - 1) 73 | end 74 | if path:sub(-1, -1) == '/' then 75 | path = path:sub(1, -2) 76 | end 77 | local csrftoken = generate_random_hex_string(16) 78 | ngx_header['set-cookie'] = ('%s=%s; Path=%s%s; HttpOnly; SameSite=Strict'):format( 79 | CSRFTOKEN_FIELD_NAME, csrftoken, url_path_prefix, path) 80 | local enctype 81 | if upload_type == 'url' then 82 | enctype = 'application/x-www-form-urlencoded' 83 | else 84 | enctype = 'multipart/form-data' 85 | end 86 | render('web/upload.html', { 87 | upload_type = upload_type, 88 | enctype = enctype, 89 | csrftoken = csrftoken, 90 | csrftoken_field = CSRFTOKEN_FIELD_NAME, 91 | content_field = upload_type, 92 | }) 93 | end, 94 | 95 | POST = function(upload_type) 96 | local headers = ngx_req_get_headers() 97 | local app_id = headers['app-id'] 98 | local direct_upload_json = false 99 | local direct_upload_plain = false 100 | if enable_upload_api and app_id then 101 | log('app_id: %s', app_id) 102 | local accept_header = headers['accept'] 103 | if type(accept_header) == 'table' then 104 | accept_header = accept_header[1] 105 | end 106 | local accept 107 | if accept_header then 108 | accept = parse_accept_header(accept_header):negotiate(MEDIA_TYPES) 109 | end 110 | if accept == 'application/json' then 111 | direct_upload_json = true 112 | ngx_header['content-type'] = 'application/json' 113 | else 114 | direct_upload_plain = true 115 | ngx_header['content-type'] = 'text/plain' 116 | end 117 | end 118 | 119 | local uploader_type 120 | if direct_upload_json or direct_upload_plain then 121 | if upload_type == 'url' then 122 | uploader_type = url_direct_uploader 123 | else 124 | uploader_type = file_direct_uploader 125 | end 126 | else 127 | if upload_type == 'url' then 128 | uploader_type = url_form_uploader 129 | else 130 | uploader_type = file_form_uploader 131 | end 132 | end 133 | 134 | local uploader, err_code, err = uploader_type:new(upload_type, tg_upload_chat_id, headers) 135 | if not uploader then 136 | log(err_code_to_log_level(err_code), 'uploader.new() error: %s: %s', err_code, err) 137 | return error(err_code, err) 138 | end 139 | local file_object 140 | file_object, err_code, err = uploader:run() 141 | uploader:close() 142 | if not file_object then 143 | log(err_code_to_log_level(err_code), 'uploader.run() error: %s: %s', err_code, err) 144 | return error(err_code, err) 145 | end 146 | local file_size = file_object.file_size 147 | local bytes_uploaded = uploader.bytes_uploaded 148 | if file_size and file_size ~= bytes_uploaded then 149 | log(ngx_WARN, 'size mismatch: file_size: %d, bytes uploaded: %d', 150 | file_size, bytes_uploaded) 151 | else 152 | log('bytes uploaded: %s', bytes_uploaded) 153 | end 154 | if uploader:is_max_file_size_exceeded(file_size or bytes_uploaded) then 155 | log('file is too big for getFile API method, return error to client') 156 | return error(413, 'the file is too big') 157 | end 158 | 159 | local media_type_id, media_type = get_media_type_id(uploader.media_type) 160 | log('tinyid media type: %s (%s)', media_type, media_type_id) 161 | 162 | local tiny_id 163 | tiny_id, err = tinyid.encode{ 164 | file_id = file_object.file_id, 165 | media_type_id = media_type_id, 166 | } 167 | if not tiny_id then 168 | log(ngx_ERR, 'failed to encode tiny_id: %s', err) 169 | return error(ngx_HTTP_INTERNAL_SERVER_ERROR) 170 | end 171 | log('tiny_id: %s', tiny_id) 172 | 173 | local render_link = render_link_factory(tiny_id) 174 | if direct_upload_json then 175 | ngx_print(json_encode{ 176 | id = tiny_id, 177 | file_size = file_size, 178 | media_type = media_type, 179 | links = { 180 | inline = render_link('il'), 181 | download = render_link('dl'), 182 | links_page = render_link('ln'), 183 | }, 184 | }) 185 | elseif direct_upload_plain then 186 | ngx_say(render_link('il')) 187 | else 188 | return ngx_redirect(render_link('ln'), ngx_HTTP_SEE_OTHER) 189 | end 190 | end 191 | 192 | } 193 | -------------------------------------------------------------------------------- /app/views/get-file.lua: -------------------------------------------------------------------------------- 1 | local base58 = require('basex').base58bitcoin 2 | 3 | local cipher = require('app.cipher') 4 | local tinyid = require('app.tinyid') 5 | local utils = require('app.utils') 6 | local constants = require('app.constants') 7 | local tg = require('app.tg') 8 | local helpers = require('app.views.helpers') 9 | local DEFAULT_MEDIA_TYPE = require('app.mediatypes').DEFAULT_TYPE 10 | 11 | local string_match = string.match 12 | local string_format = string.format 13 | 14 | local ngx_var = ngx.var 15 | local ngx_print = ngx.print 16 | local ngx_header = ngx.header 17 | local ngx_exit = ngx.exit 18 | local ngx_req = ngx.req 19 | local ngx_INFO = ngx.INFO 20 | local ngx_ERR = ngx.ERR 21 | local ngx_HTTP_NOT_MODIFIED = ngx.HTTP_NOT_MODIFIED 22 | local ngx_HTTP_NOT_FOUND = ngx.HTTP_NOT_FOUND 23 | local ngx_HTTP_BAD_GATEWAY = ngx.HTTP_BAD_GATEWAY 24 | 25 | local cipher_encrypt = cipher.encrypt 26 | local cipher_decrypt = cipher.decrypt 27 | 28 | local log = utils.log 29 | local error = utils.error 30 | local escape_uri = utils.escape_uri 31 | local unescape_ext = utils.unescape_ext 32 | local guess_extension = utils.guess_extension 33 | local parse_media_type = utils.parse_media_type 34 | local format_file_size = utils.format_file_size 35 | 36 | local tg_client = tg.client 37 | 38 | local render_link_factory = helpers.render_link_factory 39 | local render = helpers.render 40 | 41 | local TG_TYPES = constants.TG_TYPES 42 | local TG_TYPES_EXTENSIONS_MAP = constants.TG_TYPES_EXTENSIONS_MAP 43 | local GET_FILE_MODES = constants.GET_FILE_MODES 44 | local CHUNK_SIZE = constants.CHUNK_SIZE 45 | 46 | 47 | local unquote_etag = function(etag) 48 | etag = string_match(etag, '^"(.+)"$') 49 | if not etag or #etag == 0 then return nil end 50 | return etag 51 | end 52 | 53 | local encode_etag = function(etag) 54 | etag = unquote_etag(etag) 55 | if not etag then return nil end 56 | etag = base58:encode(cipher_encrypt(etag)) 57 | return string_format('"%s"', etag) 58 | end 59 | 60 | local decode_etag = function(etag) 61 | etag = unquote_etag(etag) 62 | if not etag then return nil end 63 | etag = base58:decode(etag) 64 | if not etag then return nil end 65 | etag = cipher_decrypt(etag) 66 | if not etag then return nil end 67 | return string_format('"%s"', etag) 68 | end 69 | 70 | 71 | return { 72 | 73 | GET = function(tiny_id, mode, file_name) 74 | -- decode tiny_id 75 | local tiny_id_params, tiny_id_err = tinyid.decode(tiny_id) 76 | if not tiny_id_params then 77 | log(ngx_INFO, 'tiny_id decode error: %s', tiny_id_err) 78 | return error(ngx_HTTP_NOT_FOUND) 79 | end 80 | -- get file info 81 | local client = tg_client() 82 | local resp, err = client:get_file(tiny_id_params.file_id) 83 | client:close() 84 | if err then 85 | log(ngx_ERR, 'tg api request error: %s', err) 86 | return error(ngx_HTTP_BAD_GATEWAY) 87 | end 88 | if not resp.ok then 89 | log(ngx_INFO, 'tg api response is not "ok": %s', resp.description) 90 | return error(ngx_HTTP_NOT_FOUND) 91 | end 92 | local file_path = resp.result.file_path 93 | local file_size = resp.result.file_size 94 | local media_type = tiny_id_params.media_type or DEFAULT_MEDIA_TYPE 95 | local extension 96 | -- getFile right after upload returns File without file_path field 97 | if file_path and file_path:match('^voice/.+%.oga$') then 98 | -- fix voice message file .oga extension 99 | extension = '.' .. TG_TYPES_EXTENSIONS_MAP[TG_TYPES.VOICE] 100 | else 101 | extension = guess_extension{ 102 | file_name = file_path and unescape_ext(file_path), 103 | media_type = media_type, 104 | } 105 | end 106 | 107 | -- /ln/ -> render links page 108 | 109 | if mode == GET_FILE_MODES.LINKS then 110 | render('web/file-links.html', { 111 | title = tiny_id, 112 | file_size = format_file_size(file_size), 113 | media_type = media_type, 114 | modes = GET_FILE_MODES, 115 | render_link = render_link_factory(tiny_id), 116 | extension = extension, 117 | }) 118 | return 119 | end 120 | 121 | -- /dl/ or /il/ -> stream file content from tg file storage 122 | 123 | if not file_path then 124 | return error(ngx_HTTP_BAD_GATEWAY, 'upstream did not return a file path') 125 | end 126 | -- connect to tg file storage 127 | local etag 128 | local encoded_etag = ngx_var.http_if_none_match 129 | if type(encoded_etag) == 'string' then 130 | etag = decode_etag(encoded_etag) 131 | end 132 | resp, err = client:request_file(file_path, etag) 133 | if err then 134 | client:close() 135 | log(ngx_ERR, 'tg file storage request error: %s', err) 136 | return error(ngx_HTTP_BAD_GATEWAY) 137 | end 138 | local res_status = resp.status 139 | if res_status == ngx_HTTP_NOT_MODIFIED then 140 | client:close() 141 | return ngx_exit(ngx_HTTP_NOT_MODIFIED) 142 | end 143 | 144 | if not file_name or #file_name < 1 then 145 | file_name = tiny_id .. (extension or '') 146 | end 147 | local content_disposition 148 | if mode == GET_FILE_MODES.DOWNLOAD then 149 | content_disposition = 'attachment' 150 | else 151 | content_disposition = 'inline' 152 | end 153 | 154 | local content_type, media_type_table 155 | local query = ngx_req.get_uri_args() 156 | local overridden_media_type = query.mt 157 | if type(overridden_media_type) == 'string' then 158 | media_type_table, err = parse_media_type(overridden_media_type) 159 | if not media_type_table then 160 | log(ngx_INFO, 'invalid overridden media type: %s, error: %s', overridden_media_type, err) 161 | else 162 | content_type = overridden_media_type 163 | end 164 | end 165 | if not content_type then 166 | content_type = media_type 167 | end 168 | if not media_type_table then 169 | media_type_table = parse_media_type(media_type) 170 | end 171 | if media_type_table[1] == 'text' then 172 | content_type = content_type .. '; charset=utf-8' 173 | end 174 | ngx_header['content-type'] = content_type 175 | ngx_header['content-disposition'] = ("%s; filename*=utf-8''%s"):format( 176 | content_disposition, escape_uri(file_name, true)) 177 | ngx_header['content-length'] = file_size 178 | etag = resp.headers['etag'] 179 | if type(etag) == 'string' then 180 | ngx_header['etag'] = encode_etag(etag) 181 | end 182 | 183 | local chunk 184 | while true do 185 | chunk, err = resp.body_reader(CHUNK_SIZE) 186 | if err then 187 | log(ngx_ERR, 'tg file storage read error: %s', err) 188 | break 189 | end 190 | if not chunk then break end 191 | ngx_print(chunk) 192 | end 193 | 194 | client:close() 195 | 196 | end 197 | 198 | } 199 | -------------------------------------------------------------------------------- /app/tg.lua: -------------------------------------------------------------------------------- 1 | local http = require('resty.http') 2 | local json = require('cjson.safe') 3 | 4 | local constants = require('app.constants') 5 | local config = require('app.config') 6 | local utils = require('app.utils') 7 | 8 | local tostring = tostring 9 | local string_find = string.find 10 | local string_lower = string.lower 11 | local string_format = string.format 12 | local json_encode = json.encode 13 | local json_decode = json.decode 14 | 15 | local TG_API_HOST = constants.TG_API_HOST 16 | local TG_TYPES = constants.TG_TYPES 17 | local TG_TYPE_PHOTO = TG_TYPES.PHOTO 18 | local wrap_error = utils.wrap_error 19 | local escape_uri = utils.escape_uri 20 | local set = utils.set 21 | 22 | 23 | local _TG_TOKEN = config.tg.token 24 | local _DEFAULT_REQUEST_TIMEOUT = config.tg.request_timeout 25 | local _CONNECTION_OPTIONS = { 26 | scheme = 'https', 27 | host = TG_API_HOST, 28 | } 29 | local _EXPECTED_200 = set{200} 30 | local _EXPECTED_200_201 = set{200, 201} 31 | local _EXPECTED_200_304 = set{200, 304} 32 | local _EXPECTED_200_201_400 = set{200, 201, 400} 33 | 34 | 35 | local client_mt = {} 36 | 37 | client_mt.__index = client_mt 38 | 39 | client_mt.send_message = function(self, json_body, timeout) 40 | local options = { 41 | method = 'POST', 42 | json = json_body, 43 | timeout = timeout, 44 | } 45 | return self:_call_api('sendMessage', options, _EXPECTED_200_201) 46 | end 47 | 48 | client_mt.send_document = function(self, json_body_or_options, timeout) 49 | local options 50 | if json_body_or_options.chat_id then 51 | options = { 52 | method = 'POST', 53 | json = json_body_or_options, 54 | } 55 | else 56 | options = json_body_or_options 57 | if not options.method then 58 | options.method = 'POST' 59 | end 60 | end 61 | if timeout then 62 | options.timeout = timeout 63 | end 64 | -- 400 is expected status for url uploads 65 | return self:_call_api('sendDocument', options, _EXPECTED_200_201_400) 66 | end 67 | 68 | client_mt.forward_message = function(self, json_body, timeout) 69 | local options = { 70 | method = 'POST', 71 | json = json_body, 72 | timeout = timeout, 73 | } 74 | return self:_call_api('forwardMessage', options, _EXPECTED_200_201) 75 | end 76 | 77 | client_mt.get_file = function(self, file_id, timeout) 78 | local options = { 79 | method = 'GET', 80 | query = {file_id = file_id}, 81 | timeout = timeout, 82 | } 83 | return self:_call_api('getFile', options, _EXPECTED_200) 84 | end 85 | 86 | client_mt.request_file = function(self, file_path, etag, timeout) 87 | local expected 88 | local headers 89 | if etag then 90 | headers = { 91 | ['If-None-Match'] = etag, 92 | } 93 | expected = _EXPECTED_200_304 94 | else 95 | expected = _EXPECTED_200 96 | end 97 | local params = { 98 | method = 'GET', 99 | path = string_format('/file/bot%s/%s', _TG_TOKEN, escape_uri(file_path)), 100 | headers = headers, 101 | } 102 | return self:_request(params, expected, timeout) 103 | end 104 | 105 | client_mt.close = function(self) 106 | local conn = self._conn 107 | if conn then 108 | conn:set_keepalive() 109 | self._conn = nil 110 | end 111 | end 112 | 113 | client_mt._call_api = function(self, api_method, options, expected) 114 | -- options: table: 115 | -- timeout: number | nil -- seconds; if nil, the default value is used 116 | -- method: str HTTP method 117 | -- headers: table | nil 118 | -- query: table | nil 119 | -- decode: boolean -- decode JSON response, default = true 120 | -- body: iterator function 121 | -- or 122 | -- json: table -- JSON boby 123 | -- expected: utils.set table | nil 124 | -- returns: 125 | -- resp_or_json_body | nil, err | nil 126 | local headers = {} 127 | local body = options.body 128 | local json_body = options.json 129 | if json_body then 130 | body = json_encode(json_body) 131 | headers['Content-Type'] = 'application/json' 132 | headers['Content-Length'] = #body 133 | end 134 | local opt_headers = options.headers 135 | if opt_headers then 136 | for header, value in pairs(opt_headers) do 137 | headers[header] = value 138 | end 139 | end 140 | -- lua-resty-http :request() params 141 | local params = { 142 | method = options.method, 143 | path = string_format('/bot%s/%s', _TG_TOKEN, api_method), 144 | headers = headers, 145 | query = options.query, 146 | body = body, 147 | } 148 | local resp, err = self:_request(params, expected, options.timeout) 149 | if err then 150 | return resp, wrap_error('resty.http request error', err) 151 | end 152 | local decode = options.decode 153 | if decode == nil then 154 | decode = true 155 | end 156 | if not decode then 157 | return resp 158 | end 159 | local resp_body 160 | resp_body, err = resp:read_body() 161 | if not resp_body then 162 | return resp, wrap_error('resty.http read_body error', err) 163 | end 164 | if resp_body == '' then 165 | return resp, string_format('%d: empty response body', resp.status) 166 | end 167 | local resp_json 168 | resp_json, err = json_decode(resp_body) 169 | if not resp_json then 170 | return resp, wrap_error('response decode error', err) 171 | end 172 | return resp_json 173 | end 174 | 175 | client_mt._request = function(self, params, expected, timeout) 176 | local conn, err = self:_connect() 177 | if not conn then 178 | return nil, err 179 | end 180 | if not timeout then 181 | timeout = _DEFAULT_REQUEST_TIMEOUT 182 | end 183 | conn:set_timeout(timeout * 1000) 184 | local resp 185 | resp, err = conn:request(params) 186 | if not resp then 187 | return nil, wrap_error('resty.http request error', err) 188 | end 189 | if expected then 190 | local status = resp.status 191 | if not expected[status] then 192 | return resp, string_format('unexpected tg response status: %d', status) 193 | end 194 | end 195 | return resp 196 | end 197 | 198 | client_mt._connect = function(self) 199 | local conn, err = self:_get_connection() 200 | if not conn then 201 | return nil, err 202 | end 203 | local ok 204 | ok, err = conn:connect(_CONNECTION_OPTIONS) 205 | if not ok then 206 | return nil, wrap_error('resty.http connect error', err) 207 | end 208 | conn:set_timeout(_DEFAULT_REQUEST_TIMEOUT * 1000) 209 | return conn 210 | end 211 | 212 | client_mt._get_connection = function(self) 213 | local conn = self._conn 214 | if conn then 215 | return conn 216 | end 217 | local err 218 | conn, err = http.new() 219 | if not conn then 220 | return nil, wrap_error('resty.http new error', err) 221 | end 222 | self._conn = conn 223 | return conn 224 | end 225 | 226 | local _M = {} 227 | 228 | _M.client = function() 229 | return setmetatable({}, client_mt) 230 | end 231 | 232 | _M.get_file_from_message = function(message) 233 | -- message: TG bot API Message object 234 | -- returns: 235 | -- if ok: table with keys 'object' and 'type' 236 | -- where 'object' is one of TG bot API objects (Document/Video/...) 237 | -- and 'type' is one of TG_TYPES constants 238 | -- if no file found: nil, err 239 | local file_obj, file_obj_type 240 | for _, _file_obj_type in pairs(TG_TYPES) do 241 | file_obj = message[_file_obj_type] 242 | if file_obj then 243 | file_obj_type = _file_obj_type 244 | if file_obj_type == TG_TYPE_PHOTO then 245 | file_obj = file_obj[#file_obj] 246 | end 247 | return { 248 | object = file_obj, 249 | type = file_obj_type, 250 | } 251 | end 252 | end 253 | return nil, 'no file in the message' 254 | end 255 | 256 | local URL_UPLOAD_ERROR_FAILED = 'FAILED' 257 | local URL_UPLOAD_ERROR_FAILED_PATTERN = string_lower('failed to get HTTP URL content') 258 | local URL_UPLOAD_ERROR_REJECTED = 'REJECTED' 259 | local URL_UPLOAD_ERROR_REJECTED_PATTERN = string_lower('wrong file identifier/HTTP URL specified') 260 | 261 | _M.URL_UPLOAD_ERROR_FAILED = URL_UPLOAD_ERROR_FAILED 262 | _M.URL_UPLOAD_ERROR_REJECTED = URL_UPLOAD_ERROR_REJECTED 263 | 264 | _M.get_url_upload_error_type = function(err) 265 | local err_lower = string_lower(tostring(err)) 266 | if string_find(err_lower, URL_UPLOAD_ERROR_FAILED_PATTERN, 1, true) then 267 | return URL_UPLOAD_ERROR_FAILED 268 | end 269 | if string_find(err_lower, URL_UPLOAD_ERROR_REJECTED_PATTERN, 1, true) then 270 | return URL_UPLOAD_ERROR_REJECTED 271 | end 272 | return nil 273 | end 274 | 275 | return _M 276 | -------------------------------------------------------------------------------- /app/views/webhook.lua: -------------------------------------------------------------------------------- 1 | local json = require('cjson.safe') 2 | 3 | local tinyid = require('app.tinyid') 4 | local utils = require('app.utils') 5 | local constants = require('app.constants') 6 | local helpers = require('app.views.helpers') 7 | 8 | local config = require('app.config') 9 | local tg = require('app.tg') 10 | 11 | 12 | local yield = coroutine.yield 13 | 14 | local ngx_say = ngx.say 15 | local ngx_req = ngx.req 16 | local ngx_header = ngx.header 17 | local ngx_thread_spawn = ngx.thread.spawn 18 | local ngx_HTTP_NOT_FOUND = ngx.HTTP_NOT_FOUND 19 | local ngx_INFO = ngx.INFO 20 | local ngx_ERR = ngx.ERR 21 | 22 | local json_encode = json.encode 23 | local json_decode = json.decode 24 | 25 | local log = utils.log 26 | local error = utils.error 27 | local guess_media_type = utils.guess_media_type 28 | local guess_extension = utils.guess_extension 29 | local utf8_sub = utils.utf8_sub 30 | 31 | local TG_CHAT_PRIVATE = constants.TG_CHAT_PRIVATE 32 | local TG_MAX_FILE_SIZE = constants.TG_MAX_FILE_SIZE 33 | local GET_FILE_MODES = constants.GET_FILE_MODES 34 | 35 | local tg_bot_username = config.tg.bot_username 36 | local tg_webhook_secret = config._processed.tg_webhook_secret 37 | local tg_forward_chat_id = config.tg.forward_chat_id 38 | local hide_image_download_link = config.hide_image_download_link 39 | local link_url_prefix = config._processed.link_url_prefix 40 | local enable_upload = config._processed.enable_upload 41 | local enable_upload_api = config._processed.enable_upload_api 42 | 43 | local URL_UPLOAD_ERROR_REJECTED = tg.URL_UPLOAD_ERROR_REJECTED 44 | local tg_client = tg.client 45 | local get_file_from_message = tg.get_file_from_message 46 | local get_url_upload_error_type = tg.get_url_upload_error_type 47 | 48 | local render_link_factory = helpers.render_link_factory 49 | local render_to_string = helpers.render_to_string 50 | local markdown_escape = helpers.markdown_escape 51 | 52 | -- some arbitrary multiplier, since url upload is a synchronous operation 53 | -- and the default timeout might be too short 54 | local URL_UPLOAD_TIMEOUT = config.tg.request_timeout * 5 55 | 56 | local extract_url = function(message) 57 | local entities = message.entities 58 | if not entities then 59 | return nil 60 | end 61 | for _, entity in ipairs(entities) do 62 | local entity_type = entity.type 63 | if entity_type == 'url' then 64 | local offset = entity.offset 65 | return utf8_sub(message.text, offset + 1, offset + entity.length) 66 | elseif entity_type == 'text_link' then 67 | return entity.url 68 | end 69 | end 70 | return nil 71 | end 72 | 73 | 74 | local extract_file_and_reply_to_user = function(reply_callback, file_message, user_message) 75 | -- reply_callback: function(user_message, template_path, context) 76 | -- file_message: a message with a file to extract 77 | -- user_message: an original message from a user to reply; if nil, 78 | -- then user_message = file_message 79 | if user_message == nil then 80 | user_message = file_message 81 | end 82 | local file, err = get_file_from_message(file_message) 83 | if not file then 84 | log(err) 85 | return reply_callback(user_message, 'bot/err-no-file.txt') 86 | end 87 | local file_obj = file.object 88 | local file_obj_type = file.type 89 | if not file_obj.file_id then 90 | log('no file_id') 91 | return reply_callback(user_message, 'bot/err-no-file.txt') 92 | end 93 | if file_obj.file_size and file_obj.file_size > TG_MAX_FILE_SIZE then 94 | return reply_callback(user_message, 'bot/err-file-too-big.txt') 95 | end 96 | local media_type_id, media_type = guess_media_type(file_obj, file_obj_type) 97 | local tiny_id = tinyid.encode{ 98 | file_id = file_obj.file_id, 99 | media_type_id = media_type_id, 100 | } 101 | local extension = guess_extension{ 102 | file_obj = file_obj, 103 | file_obj_type = file_obj_type, 104 | media_type = media_type, 105 | } 106 | local hide_download_link = ( 107 | hide_image_download_link 108 | and media_type and media_type:sub(1, 6) == 'image/' 109 | ) 110 | log('file_obj_type: %s', file_obj_type) 111 | log('media_type: %s -> %s (%s)', 112 | file_obj.mime_type, media_type, media_type_id) 113 | log('file_id: %s (%s bytes)', file_obj.file_id, file_obj.file_size) 114 | log('tiny_id: %s', tiny_id) 115 | return reply_callback(user_message, 'bot/ok-links.txt', { 116 | modes = GET_FILE_MODES, 117 | render_link = render_link_factory(tiny_id), 118 | extension = extension and markdown_escape(extension), 119 | hide_download_link = hide_download_link, 120 | }) 121 | 122 | end 123 | 124 | 125 | local send_webhook_response = function(user_message, template_path, context) 126 | local chat = user_message.chat 127 | -- context table mutation! 128 | if chat.type ~= TG_CHAT_PRIVATE then 129 | context = context or {} 130 | context.user = user_message.from 131 | end 132 | ngx_header['content-type'] = 'application/json' 133 | ngx_say(json_encode{ 134 | method = 'sendMessage', 135 | chat_id = chat.id, 136 | text = render_to_string(template_path, context), 137 | parse_mode = 'markdown', 138 | disable_web_page_preview = true, 139 | }) 140 | end 141 | 142 | 143 | local send_message_to_user = function(user_message, template_path, context) 144 | local chat = user_message.chat 145 | -- context table mutation! 146 | if chat.type ~= TG_CHAT_PRIVATE then 147 | context = context or {} 148 | context.user = user_message.from 149 | end 150 | local client = tg_client() 151 | local resp, err = client:send_message({ 152 | chat_id = chat.id, 153 | text = render_to_string(template_path, context), 154 | parse_mode = 'markdown', 155 | disable_web_page_preview = true, 156 | }) 157 | client:close() 158 | if err then 159 | log(ngx_ERR, 'tg api request error: %s', err) 160 | return 161 | end 162 | if not resp.ok then 163 | log(ngx_INFO, 'tg api response is not "ok": %s', resp.description) 164 | end 165 | end 166 | 167 | 168 | local upload_url_and_send_message_to_user = function(user_message, url) 169 | -- yield to return from ngx.thread.spawn ASAP 170 | yield() 171 | log('uploading url: %s', url) 172 | local client = tg_client() 173 | local resp, err = client:send_document({ 174 | chat_id = config.tg.upload_chat_id, 175 | document = url, 176 | }, URL_UPLOAD_TIMEOUT) 177 | client:close() 178 | if err then 179 | log(ngx_ERR, 'tg api request error: %s', err) 180 | return 181 | end 182 | if not resp.ok then 183 | log(ngx_INFO, 'tg api response is not "ok": %s', resp.description) 184 | local template 185 | if get_url_upload_error_type(resp.description) == URL_UPLOAD_ERROR_REJECTED then 186 | template = 'bot/err-url-upload-rejected.txt' 187 | else 188 | -- URL_UPLOAD_ERROR_FAILED or unknown error 189 | template = 'bot/err-url-upload-failed.txt' 190 | end 191 | send_message_to_user(user_message, template) 192 | return 193 | end 194 | local file_message = resp.result 195 | if not file_message then 196 | log(ngx_INFO, 'tg api response has no "result"') 197 | return 198 | end 199 | extract_file_and_reply_to_user(send_message_to_user, file_message, user_message) 200 | end 201 | 202 | 203 | local forward_message = function(message) 204 | -- yield to return from ngx.thread.spawn ASAP 205 | yield() 206 | local client = tg_client() 207 | local resp, err = client:forward_message({ 208 | chat_id = tg_forward_chat_id, 209 | from_chat_id = message.chat.id, 210 | message_id = message.message_id, 211 | }) 212 | client:close() 213 | if err then 214 | log(ngx_ERR, 'tg api request error: %s', err) 215 | return 216 | end 217 | if not resp.ok then 218 | log(ngx_INFO, 'tg api response is not "ok": %s', resp.description) 219 | end 220 | end 221 | 222 | 223 | return { 224 | 225 | initial = function(secret) 226 | if secret ~= tg_webhook_secret then 227 | return error(ngx_HTTP_NOT_FOUND) 228 | end 229 | end, 230 | 231 | POST = function() 232 | ngx_req.read_body() 233 | local req_body = ngx_req.get_body_data() 234 | local req_json = json_decode(req_body) 235 | log('webhook request: %s', req_json and json_encode(req_json) or req_body) 236 | local message = req_json and req_json.message 237 | if not message or not message.chat then 238 | return 239 | end 240 | local is_groupchat = message.chat.type ~= TG_CHAT_PRIVATE 241 | -- tricky way to ignore groupchat service messages (e.g., new_chat_member) 242 | if is_groupchat and not message.reply_to_message then 243 | return 244 | end 245 | if tg_forward_chat_id then 246 | ngx_thread_spawn(forward_message, message) 247 | end 248 | if message.text then 249 | local url = extract_url(message) 250 | if url then 251 | ngx_thread_spawn(upload_url_and_send_message_to_user, message, url) 252 | return 253 | end 254 | local command, bot_username = message.text:match('^/([%w_]+)@?([%w_]*)') 255 | if not command then 256 | return send_webhook_response(message, 'bot/err-no-file.txt') 257 | end 258 | -- ignore groupchat commands to other bots / commands without bot username 259 | if is_groupchat and bot_username ~= tg_bot_username then 260 | return 261 | end 262 | return send_webhook_response(message, 'bot/ok-help.txt', { 263 | link_url_prefix = link_url_prefix, 264 | enable_upload = enable_upload, 265 | enable_upload_api = enable_upload_api, 266 | }) 267 | end 268 | extract_file_and_reply_to_user(send_webhook_response, message) 269 | end 270 | 271 | } 272 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | /*** common ***/ 2 | 3 | /* latin */ 4 | @font-face { 5 | font-family: 'Source Sans Pro'; 6 | font-style: normal; 7 | font-weight: 400; 8 | src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url('sourcesanspro-regular.woff2') format('woff2'); 9 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 10 | } 11 | 12 | /* latin */ 13 | @font-face { 14 | font-family: 'Source Sans Pro'; 15 | font-style: italic; 16 | font-weight: 400; 17 | src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url('sourcesanspro-italic.woff2') format('woff2'); 18 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 19 | } 20 | 21 | /* latin */ 22 | @font-face { 23 | font-family: 'Source Code Pro'; 24 | font-style: normal; 25 | font-weight: 400; 26 | src: local('Source Code Pro Regular'), local('SourceCodePro-Regular'), url('sourcecodepro-regular.woff2') format('woff2'); 27 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 28 | } 29 | 30 | * { 31 | padding: 0; 32 | margin: 0; 33 | } 34 | 35 | body { 36 | background-color: #fff; 37 | font-family: 'Source Sans Pro', sans-serif; 38 | color: #333; 39 | } 40 | 41 | a { 42 | color: #337ab7; 43 | text-decoration: none; 44 | outline: 0; 45 | } 46 | 47 | a:hover, a:focus { 48 | color: #23527c; 49 | text-decoration: underline; 50 | } 51 | 52 | h1 { 53 | font-size: 3rem; 54 | margin: 2.25rem 0 1rem; 55 | } 56 | 57 | h2 { 58 | font-size: 2.2rem; 59 | margin: 2rem 0 1rem; 60 | } 61 | 62 | h3 { 63 | font-size: 1.76rem; 64 | margin: 1.5rem 0 1rem; 65 | } 66 | 67 | h4 { 68 | font-size: 1.5rem; 69 | margin: 1.25rem 0 1rem; 70 | } 71 | 72 | h5 { 73 | font-size: 1.25rem; 74 | margin: 1.12rem 0 1rem; 75 | } 76 | 77 | h6 { 78 | font-size: 1.12rem; 79 | margin: 1rem 0 1rem; 80 | } 81 | 82 | ul { 83 | list-style-position: inside; 84 | margin-left: 1rem; 85 | } 86 | 87 | .monospace { 88 | font-family: 'Source Code Pro', monospace; 89 | } 90 | 91 | code { 92 | font-family: 'Source Code Pro', monospace; 93 | font-size: .8em; 94 | padding: 1px 4px; 95 | overflow-wrap: break-word; 96 | color: #555; 97 | background-color: #fcfcfc; 98 | border: 1px solid #e0e0e0; 99 | } 100 | 101 | .codeblock { 102 | font-family: 'Source Code Pro', monospace; 103 | font-size: .9em; 104 | padding: .5em .7em; 105 | margin: .5em 0; 106 | overflow-wrap: break-word; 107 | white-space: pre-wrap; 108 | color: #555; 109 | background-color: #fcfcfc; 110 | border: 1px solid #e0e0e0; 111 | } 112 | 113 | code .comment, .codeblock .comment { 114 | color: #999; 115 | } 116 | 117 | .container { 118 | height: 100vh; 119 | display: flex; 120 | flex-direction: column; 121 | align-items: center; 122 | } 123 | 124 | .container > :nth-child(1) { 125 | flex: 0 0 3rem; 126 | } 127 | 128 | .container > :nth-child(2) { 129 | flex: 1 1 auto; 130 | } 131 | 132 | .container > :nth-child(3) { 133 | flex: 0 0 auto; 134 | } 135 | 136 | header { 137 | box-sizing: border-box; 138 | width: 100%; 139 | padding: 0 0.6rem; 140 | display: flex; 141 | justify-content: space-between; 142 | align-items: center; 143 | background: #222; 144 | color: #555; 145 | } 146 | 147 | header a { 148 | color: #bbb; 149 | } 150 | 151 | header a:hover, header a:focus { 152 | color: #fff; 153 | text-decoration: none; 154 | } 155 | 156 | header .logo { 157 | font-size: 1.5rem; 158 | padding-bottom: 0.2rem; 159 | white-space: nowrap; 160 | } 161 | 162 | header .logo sup { 163 | font-size: 0.7em; 164 | display: inline-block; 165 | transform: scaleY(0.7); 166 | } 167 | 168 | header nav { 169 | padding-bottom: 0.2em; 170 | } 171 | 172 | header nav ul { 173 | display: flex; 174 | flex-direction: row; 175 | flex-wrap: wrap; 176 | justify-content: flex-end; 177 | } 178 | 179 | header nav li { 180 | white-space: nowrap; 181 | display: inline; 182 | list-style-type: none; 183 | } 184 | 185 | header nav span { 186 | display: inline-block; 187 | margin: 0 2px; 188 | color: #777; 189 | } 190 | 191 | .content { 192 | display: flex; 193 | align-items: center; 194 | padding: 1rem; 195 | } 196 | 197 | footer { 198 | background-color: #fafafa; 199 | width: 100%; 200 | color: #777; 201 | display: flex; 202 | flex-direction: row; 203 | flex-wrap: wrap; 204 | align-items: center; 205 | justify-content: space-evenly; 206 | font-size: 0.8rem; 207 | padding-top: 5px; 208 | } 209 | 210 | footer .row { 211 | padding-bottom: 5px; 212 | } 213 | 214 | footer .row .bull::before { 215 | content: "•"; 216 | } 217 | 218 | /*** main page ***/ 219 | 220 | .main-page .info { 221 | text-align: center; 222 | font-size: 1.4rem; 223 | line-height: 2rem; 224 | margin-bottom: 1rem; 225 | } 226 | 227 | .main-page .info sup { 228 | font-size: .7rem; 229 | } 230 | 231 | .main-page .footnote { 232 | margin-top: 1.5rem; 233 | padding: 0.5rem; 234 | font-size: 0.9rem; 235 | border-top: 1px solid #ccc; 236 | } 237 | 238 | /*** docs page ***/ 239 | 240 | .docs-page { 241 | margin: 1rem 0 3rem 0; 242 | font-size: 1.2rem; 243 | line-height: 1.8rem; 244 | } 245 | 246 | @media screen and (min-width: 900px) { 247 | .docs-page { 248 | width: 800px; 249 | } 250 | } 251 | 252 | @media screen and (max-width: 900px) { 253 | .docs-page { 254 | width: 90vw; 255 | } 256 | } 257 | 258 | .docs-page p { 259 | margin-bottom: .3rem; 260 | } 261 | 262 | /*** donate page ***/ 263 | 264 | .donate-page { 265 | margin: 1rem 0 3rem 0; 266 | } 267 | 268 | @media screen and (min-width: 900px) { 269 | .donate-page { 270 | width: 800px; 271 | } 272 | } 273 | 274 | @media screen and (max-width: 900px) { 275 | .donate-page { 276 | width: 90vw; 277 | } 278 | } 279 | 280 | .donate-page { 281 | font-size: 1.4rem; 282 | line-height: 2rem; 283 | text-align: center; 284 | } 285 | 286 | .donate-page .intro { 287 | margin-bottom: 3rem; 288 | } 289 | 290 | .donate-page .block { 291 | margin-bottom: .5rem; 292 | } 293 | 294 | /*** file links page ***/ 295 | 296 | @media screen and (max-width: 1200px) { 297 | .file-links-page { 298 | width: 90vw; 299 | } 300 | 301 | .file-links-page .row { 302 | flex-wrap: wrap; 303 | } 304 | } 305 | 306 | @media screen and (min-width: 1200px) { 307 | .file-links-page { 308 | width: 70vw; 309 | } 310 | } 311 | 312 | @media screen and (min-width: 1600px) { 313 | .file-links-page { 314 | width: 50vw; 315 | } 316 | } 317 | 318 | .file-links-page .header { 319 | margin-bottom: 2rem; 320 | } 321 | 322 | .file-links-page .row { 323 | width: 100%; 324 | display: flex; 325 | margin: .3rem 0; 326 | } 327 | 328 | .file-links-page .row > * { 329 | display: block; 330 | padding: .4rem; 331 | margin-right: .3rem; 332 | white-space: nowrap; 333 | overflow: hidden; 334 | text-overflow: ellipsis; 335 | } 336 | 337 | .file-links-page .row input[type="text"] { 338 | /* workaround for weird (bolder) form input text rendering in chrome */ 339 | font-size: .95em; 340 | font-family: inherit; 341 | border: 0; 342 | } 343 | 344 | .file-links-page .row > :first-child { 345 | background: #f0f0f0; 346 | flex: 1 1 20%; 347 | } 348 | 349 | .file-links-page .row > :last-child { 350 | background: #fafafa; 351 | flex: 5 1 80%; 352 | } 353 | 354 | .file-links-page .slider { 355 | display: inline-block; 356 | vertical-align: sub; 357 | padding: .2em; 358 | width: 1.5em; 359 | border-radius: 1em; 360 | background: #bbb; 361 | } 362 | 363 | .file-links-page .slider .dot { 364 | display: block; 365 | width: .7em; 366 | height: .7em; 367 | border-radius: .7em; 368 | background: #fff; 369 | } 370 | 371 | .file-links-page #ext-toggle { 372 | display: none; 373 | } 374 | 375 | .file-links-page .ext-label { 376 | display: block; 377 | margin: .5rem 0; 378 | cursor: pointer; 379 | } 380 | 381 | .file-links-page #ext-toggle:checked ~ .ext-label .slider { 382 | background: #23527c; 383 | } 384 | 385 | .file-links-page #ext-toggle:checked ~ .ext-label .slider .dot { 386 | float: right; 387 | } 388 | 389 | .file-links-page #ext-toggle:checked ~ .ext-off { 390 | display: none; 391 | } 392 | 393 | .file-links-page #ext-toggle:not(:checked) ~ .ext-on { 394 | display: none; 395 | } 396 | 397 | /*** upload page ***/ 398 | 399 | .upload-page .upload-form { 400 | display: flex; 401 | align-items: center; 402 | justify-content: center; 403 | } 404 | 405 | .upload-page .upload-form.vertical { 406 | flex-direction: column; 407 | } 408 | 409 | .upload-page .upload-form input { 410 | padding: .2rem; 411 | } 412 | 413 | .upload-page .upload-form input[type="file"] { 414 | width: 40rem; 415 | max-width: 70vw; 416 | background: #fafafa; 417 | margin-right: .2rem; 418 | } 419 | 420 | .upload-page .upload-form input[type="url"] { 421 | width: 40rem; 422 | max-width: 70vw; 423 | margin-right: .2rem; 424 | } 425 | 426 | .upload-page .upload-form textarea { 427 | width: 80vw; 428 | height: 60vh; 429 | margin-bottom: 1rem; 430 | } 431 | 432 | /*** error page ***/ 433 | 434 | .error-page { 435 | text-align: center; 436 | } 437 | 438 | .error-page .error { 439 | font-size: 4rem; 440 | padding: 1rem 0; 441 | } 442 | 443 | .error-page .error > span { 444 | color: #777; 445 | } 446 | 447 | .error-page .description { 448 | font-size: 1.5rem; 449 | color: #555; 450 | } 451 | -------------------------------------------------------------------------------- /app/utils.lua: -------------------------------------------------------------------------------- 1 | local random_bytes = require('resty.random').bytes 2 | local to_hex = require('resty.string').to_hex 3 | local raw_log = require('ngx.errlog').raw_log 4 | 5 | local constants = require('app.constants') 6 | local mediatypes = require('app.mediatypes') 7 | 8 | 9 | local string_match = string.match 10 | local string_byte = string.byte 11 | local string_sub = string.sub 12 | local string_format = string.format 13 | local debug_getinfo = debug.getinfo 14 | local ngx_exec = ngx.exec 15 | local ngx_DEBUG = ngx.DEBUG 16 | 17 | local TG_TYPES_MEDIA_TYPES_MAP = constants.TG_TYPES_MEDIA_TYPES_MAP 18 | local TG_TYPES_EXTENSIONS_MAP = constants.TG_TYPES_EXTENSIONS_MAP 19 | local TG_TYPE_STICKER = constants.TG_TYPES.STICKER 20 | local DEFAULT_TYPE_ID = mediatypes.DEFAULT_TYPE_ID 21 | local DEFAULT_TYPE = mediatypes.DEFAULT_TYPE 22 | local TYPE_ID_MAP = mediatypes.TYPE_ID_MAP 23 | local ID_TYPE_MAP = mediatypes.ID_TYPE_MAP 24 | local TYPE_EXT_MAP = mediatypes.TYPE_EXT_MAP 25 | 26 | 27 | local LOG_LEVEL_NAMES = { 28 | [ngx.STDERR] = 'STDERR', 29 | [ngx.EMERG] = 'EMERG', 30 | [ngx.ALERT] = 'ALERT', 31 | [ngx.CRIT] = 'CRIT', 32 | [ngx.ERR] = 'ERR', 33 | [ngx.WARN] = 'WARN', 34 | [ngx.NOTICE] = 'NOTICE', 35 | [ngx.INFO] = 'INFO', 36 | [ngx_DEBUG] = 'DEBUG', 37 | } 38 | 39 | 40 | local _M = {} 41 | 42 | local _error_mt = { 43 | __tostring = function(self) 44 | local error = self._error 45 | if error == nil then 46 | return self._prefix 47 | end 48 | return string_format('%s: %s', self._prefix, error) 49 | end, 50 | } 51 | 52 | _M.wrap_error = function(prefix, error) 53 | local error_t = { 54 | _prefix = prefix, 55 | _error = error, 56 | } 57 | return setmetatable(error_t, _error_mt) 58 | end 59 | 60 | _M.log = function(...) 61 | local level, message = ... 62 | local args_offset 63 | if type(level) ~= 'number' then 64 | message = level 65 | level = ngx_DEBUG 66 | args_offset = 2 67 | else 68 | args_offset = 3 69 | end 70 | if select('#', ...) >= args_offset then 71 | message = string_format(message, select(args_offset, ...)) 72 | end 73 | local info = debug_getinfo(2, 'Sln') 74 | message = ('%s:%s: %s:\n\n*** [%s] %s\n'):format( 75 | info.short_src:match('//(app/.+)'), info.currentline, 76 | info.name, LOG_LEVEL_NAMES[level], message) 77 | raw_log(level, message) 78 | end 79 | 80 | _M.error = function(status, description) 81 | return ngx_exec('/error', {status = status, description = description}) 82 | end 83 | 84 | _M.generate_random_hex_string = function(size) 85 | return to_hex(random_bytes(size)) 86 | end 87 | 88 | _M.encode_urlsafe_base64 = function(to_encode) 89 | local encoded = ngx.encode_base64(to_encode, true) 90 | if not encoded then 91 | return nil, 'base64 encode error' 92 | end 93 | encoded = encoded:gsub('[%+/]', {['+'] = '-', ['/'] = '_' }) 94 | return encoded 95 | end 96 | 97 | _M.decode_urlsafe_base64 = function(to_decode) 98 | to_decode = to_decode:gsub('[-_]', {['-'] = '+', ['_'] = '/' }) 99 | local decoded = ngx.decode_base64(to_decode) 100 | if not decoded then 101 | return nil, 'base64 decode error' 102 | end 103 | return decoded 104 | end 105 | 106 | _M.is_http_url = function(str) 107 | return string_match(str, '^https?://.+') ~= nil 108 | end 109 | 110 | _M.escape_uri = function(uri, escape_slashes) 111 | if escape_slashes then return ngx.escape_uri(uri) end 112 | return uri:gsub('[^/]+', ngx.escape_uri) 113 | end 114 | 115 | local split_ext = function(path, exclude_dot) 116 | local root, ext = path:match('(.*[^/]+)%.([^./]+)$') 117 | root = root or path 118 | if ext and not exclude_dot then 119 | ext = '.' .. ext 120 | end 121 | return root, ext 122 | end 123 | 124 | _M.split_ext = split_ext 125 | 126 | _M.escape_ext = function(filename) 127 | local root, ext = split_ext(filename) 128 | if not ext then 129 | ext = '' 130 | elseif ext:lower() == '.gif' then 131 | ext = '._if' 132 | end 133 | return root .. ext 134 | end 135 | 136 | _M.unescape_ext = function(filename) 137 | local root, ext = split_ext(filename) 138 | if not ext then 139 | ext = '' 140 | elseif ext == '._if' then 141 | ext = '.gif' 142 | end 143 | return root .. ext 144 | end 145 | 146 | _M.get_substring = function(str, start, length, blank_to_nil) 147 | local stop, next 148 | if not length then 149 | stop = #str 150 | else 151 | stop = start + length - 1 152 | if stop < #str then next = stop + 1 end 153 | end 154 | local substr = str:sub(start, stop) 155 | if blank_to_nil and #substr == 0 then substr = nil end 156 | return substr, next 157 | end 158 | 159 | local parse_media_type = function(media_type) 160 | local type_, subtype, suffix = media_type:match( 161 | '^([%w_-]+)/([.%w_-]+)%+?([%w_-]-)$') 162 | if not type_ then 163 | return nil, 'Media type parse error' 164 | end 165 | local has_x = subtype:sub(1, 2) == 'x-' 166 | -- [1] -- string, type, non-empty 167 | -- [2] -- string, subtype including tree and x- prefix (if any) 168 | -- [3] -- string, suffix excluding plus sign, may be empty 169 | -- [4] -- boolean, true if subtype has x- prefix 170 | return {type_, subtype, suffix, has_x} 171 | end 172 | 173 | _M.parse_media_type = parse_media_type 174 | 175 | local _render_media_type = function(type_, subtype, suffix) 176 | if suffix and suffix ~= '' then 177 | return ('%s/%s+%s'):format(type_, subtype, suffix) 178 | end 179 | return ('%s/%s'):format(type_, subtype) 180 | end 181 | 182 | local render_media_type = function(media_type_table, with_x) 183 | -- with_x: boolean -- force adding or removing x- prefix 184 | -- if with_x is not set (nil), keep x- prefix as is 185 | local type_, subtype, suffix, has_x = unpack(media_type_table) 186 | if with_x ~= nil then 187 | if with_x and not has_x then 188 | subtype = 'x-' .. subtype 189 | end 190 | if not with_x and has_x then 191 | subtype = subtype:sub(3) 192 | end 193 | end 194 | return _render_media_type(type_, subtype, suffix) 195 | end 196 | 197 | _M.render_media_type = render_media_type 198 | 199 | local _get_media_type_id = function(media_type) 200 | if media_type == DEFAULT_TYPE then 201 | return DEFAULT_TYPE_ID, DEFAULT_TYPE 202 | end 203 | local media_type_id = TYPE_ID_MAP[media_type] 204 | if media_type_id then 205 | return media_type_id, ID_TYPE_MAP[media_type_id] 206 | end 207 | return nil, media_type 208 | end 209 | 210 | local get_media_type_id = function(media_type) 211 | -- NB: media_type is modified across this function 212 | local media_type_id 213 | 214 | -- first, try media_type as is 215 | media_type_id, media_type = _get_media_type_id(media_type) 216 | if media_type_id then 217 | return media_type_id, media_type 218 | end 219 | 220 | local media_type_table = parse_media_type(media_type) 221 | if not media_type_table then 222 | return DEFAULT_TYPE_ID, DEFAULT_TYPE 223 | end 224 | local type_, subtype, suffix, has_x = unpack(media_type_table) 225 | 226 | -- try with x- prefix added/removed 227 | media_type = render_media_type(media_type_table, not has_x) 228 | media_type_id, media_type = _get_media_type_id(media_type) 229 | if media_type_id then 230 | return media_type_id, media_type 231 | end 232 | 233 | -- try with {type}/{subtype}+xml (e.g., text/html+xml) normalized 234 | if suffix == 'xml' then 235 | if subtype == 'html' then 236 | media_type = _render_media_type(type_, 'html') 237 | else 238 | media_type = _render_media_type(type_, 'xml') 239 | end 240 | media_type_id, media_type = _get_media_type_id(media_type) 241 | if media_type_id then 242 | return media_type_id, media_type 243 | end 244 | end 245 | 246 | -- fallback unknown 'text/{subtype}' to 'text/plain' 247 | if type_ == 'text' then 248 | return _get_media_type_id('text/plain') 249 | end 250 | 251 | return DEFAULT_TYPE_ID, DEFAULT_TYPE 252 | end 253 | 254 | _M.get_media_type_id = get_media_type_id 255 | 256 | _M.guess_media_type = function(file_obj, file_obj_type) 257 | local media_type = file_obj.mime_type and file_obj.mime_type:lower() 258 | -- guess by tg object 'mime_type' property 259 | if media_type then 260 | return get_media_type_id(media_type) 261 | end 262 | if file_obj_type == TG_TYPE_STICKER then 263 | -- special case for stickers 264 | if file_obj.is_animated then 265 | -- tgs -> fallback to default media type, i.e., 'application/octet-stream' 266 | media_type = DEFAULT_TYPE 267 | else 268 | media_type = 'image/webp' 269 | end 270 | else 271 | -- otherwise guess by tg object type 272 | media_type = TG_TYPES_MEDIA_TYPES_MAP[file_obj_type] 273 | end 274 | if media_type then 275 | return get_media_type_id(media_type) 276 | end 277 | return DEFAULT_TYPE_ID, DEFAULT_TYPE 278 | end 279 | 280 | _M.guess_extension = function(params) 281 | -- params combinations: 282 | -- * file_obj AND file_obj_type 283 | -- * file_name 284 | -- * nothing 285 | -- optional params: media_type, exclude_dot 286 | local ext, file_name, _ 287 | if params.file_obj then 288 | file_name = params.file_obj.file_name 289 | else 290 | file_name = params.file_name 291 | end 292 | if file_name then 293 | _, ext = _M.split_ext(file_name, true) 294 | end 295 | if not ext and params.file_obj_type then 296 | ext = TG_TYPES_EXTENSIONS_MAP[params.file_obj_type] 297 | end 298 | if not ext and params.media_type then 299 | ext = TYPE_EXT_MAP[params.media_type] 300 | end 301 | if ext and not params.exclude_dot then 302 | return '.' .. ext 303 | end 304 | return ext 305 | end 306 | 307 | _M.format_file_size = function(size) 308 | if size == 1 then 309 | return '1 byte' 310 | end 311 | local byte_size = ('%d bytes'):format(size) 312 | if size < 1024 then 313 | return byte_size 314 | end 315 | local unit 316 | local unit_size 317 | if size < 1048576 then 318 | unit = 'kiB' 319 | unit_size = size / 1024 320 | else 321 | unit = 'MiB' 322 | unit_size = size / 1048576 323 | end 324 | return ('%.1f %s (%s)'):format(unit_size, unit, byte_size) 325 | end 326 | 327 | _M.set = function(item_t) 328 | local set_t = {} 329 | for _, item in ipairs(item_t) do 330 | set_t[item] = true 331 | end 332 | return set_t 333 | end 334 | 335 | local _utf8_next = function(str, at) 336 | -- returns next code point byte index, current code point width 337 | if at > #str then 338 | return nil 339 | end 340 | local width 341 | local byte = string_byte(str, at) 342 | if byte < 192 then 343 | width = 1 344 | elseif byte < 224 then 345 | width = 2 346 | elseif byte < 240 then 347 | width = 3 348 | else 349 | width = 4 350 | end 351 | return at + width, width 352 | end 353 | 354 | local _utf8_scan = function(str) 355 | -- iterator, string, position 356 | return _utf8_next, str, 1 357 | end 358 | 359 | _M.utf8_sub = function(str, from, to) 360 | if not from or from < 1 then error('from must be positive') end 361 | if to and to < 1 then error('to must be positive') end 362 | if to < from then error('to must be >= from') end 363 | local cur = 1 -- current code point index 364 | local from_byte 365 | for next_byte, width in _utf8_scan(str) do 366 | if cur == from then 367 | from_byte = next_byte - width 368 | if not to then 369 | return string_sub(str, from_byte) 370 | end 371 | end 372 | if cur == to then 373 | return string_sub(str, from_byte, next_byte - 1) 374 | end 375 | cur = cur + 1 376 | end 377 | end 378 | 379 | return _M 380 | --------------------------------------------------------------------------------