Compare commits
10 Commits
Author | SHA1 | Date |
---|---|---|
aewens | 99be0c244d | |
aewens | 81bf0a5cca | |
aewens | b3fd8fdbc0 | |
aewens | be8adb6d7a | |
aewens | 67d781bd7c | |
aewens | 90a03da496 | |
Ben Harris | 42c285a0ff | |
Ben Harris | 315f1bfe24 | |
Ben Harris | f2da85e437 | |
tilde services | 13816f1d80 |
|
@ -1,5 +1,5 @@
|
|||
__pycache__/
|
||||
.py[cod]
|
||||
instance/
|
||||
db.sqlite
|
||||
up/
|
||||
venv/
|
||||
*.pyc
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
[Unit]
|
||||
Description=Prune 0x0 files
|
||||
After=remote-fs.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=nullptr
|
||||
WorkingDirectory=/path/to/0x0
|
||||
BindPaths=/path/to/0x0
|
||||
|
||||
Environment=FLASK_APP=fhost
|
||||
ExecStart=/usr/bin/flask prune
|
||||
ProtectProc=noaccess
|
||||
ProtectSystem=strict
|
||||
ProtectHome=tmpfs
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
ProtectKernelLogs=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -1,9 +0,0 @@
|
|||
[Unit]
|
||||
Description=Prune 0x0 files
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
|
@ -1,22 +0,0 @@
|
|||
[Unit]
|
||||
Description=Scan 0x0 files with ClamAV
|
||||
After=remote-fs.target clamd.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=nullptr
|
||||
WorkingDirectory=/path/to/0x0
|
||||
BindPaths=/path/to/0x0
|
||||
|
||||
Environment=FLASK_APP=fhost
|
||||
ExecStart=/usr/bin/flask vscan
|
||||
ProtectProc=noaccess
|
||||
ProtectSystem=strict
|
||||
ProtectHome=tmpfs
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
ProtectKernelLogs=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -1,9 +0,0 @@
|
|||
[Unit]
|
||||
Description=Scan 0x0 files with ClamAV
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
300
LICENSE
300
LICENSE
|
@ -1,287 +1,13 @@
|
|||
EUROPEAN UNION PUBLIC LICENCE v. 1.2
|
||||
EUPL © the European Union 2007, 2016
|
||||
|
||||
This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined
|
||||
below) which is provided under the terms of this Licence. Any use of the Work,
|
||||
other than as authorised under this Licence is prohibited (to the extent such
|
||||
use is covered by a right of the copyright holder of the Work).
|
||||
|
||||
The Work is provided under the terms of this Licence when the Licensor (as
|
||||
defined below) has placed the following notice immediately following the
|
||||
copyright notice for the Work:
|
||||
|
||||
Licensed under the EUPL
|
||||
|
||||
or has expressed by any other means his willingness to license under the EUPL.
|
||||
|
||||
1. Definitions
|
||||
|
||||
In this Licence, the following terms have the following meaning:
|
||||
|
||||
- ‘The Licence’: this Licence.
|
||||
|
||||
- ‘The Original Work’: the work or software distributed or communicated by the
|
||||
Licensor under this Licence, available as Source Code and also as Executable
|
||||
Code as the case may be.
|
||||
|
||||
- ‘Derivative Works’: the works or software that could be created by the
|
||||
Licensee, based upon the Original Work or modifications thereof. This Licence
|
||||
does not define the extent of modification or dependence on the Original Work
|
||||
required in order to classify a work as a Derivative Work; this extent is
|
||||
determined by copyright law applicable in the country mentioned in Article 15.
|
||||
|
||||
- ‘The Work’: the Original Work or its Derivative Works.
|
||||
|
||||
- ‘The Source Code’: the human-readable form of the Work which is the most
|
||||
convenient for people to study and modify.
|
||||
|
||||
- ‘The Executable Code’: any code which has generally been compiled and which is
|
||||
meant to be interpreted by a computer as a program.
|
||||
|
||||
- ‘The Licensor’: the natural or legal person that distributes or communicates
|
||||
the Work under the Licence.
|
||||
|
||||
- ‘Contributor(s)’: any natural or legal person who modifies the Work under the
|
||||
Licence, or otherwise contributes to the creation of a Derivative Work.
|
||||
|
||||
- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of
|
||||
the Work under the terms of the Licence.
|
||||
|
||||
- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending,
|
||||
renting, distributing, communicating, transmitting, or otherwise making
|
||||
available, online or offline, copies of the Work or providing access to its
|
||||
essential functionalities at the disposal of any other natural or legal
|
||||
person.
|
||||
|
||||
2. Scope of the rights granted by the Licence
|
||||
|
||||
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||
sublicensable licence to do the following, for the duration of copyright vested
|
||||
in the Original Work:
|
||||
|
||||
- use the Work in any circumstance and for all usage,
|
||||
- reproduce the Work,
|
||||
- modify the Work, and make Derivative Works based upon the Work,
|
||||
- communicate to the public, including the right to make available or display
|
||||
the Work or copies thereof to the public and perform publicly, as the case may
|
||||
be, the Work,
|
||||
- distribute the Work or copies thereof,
|
||||
- lend and rent the Work or copies thereof,
|
||||
- sublicense rights in the Work or copies thereof.
|
||||
|
||||
Those rights can be exercised on any media, supports and formats, whether now
|
||||
known or later invented, as far as the applicable law permits so.
|
||||
|
||||
In the countries where moral rights apply, the Licensor waives his right to
|
||||
exercise his moral right to the extent allowed by law in order to make effective
|
||||
the licence of the economic rights here above listed.
|
||||
|
||||
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
|
||||
any patents held by the Licensor, to the extent necessary to make use of the
|
||||
rights granted on the Work under this Licence.
|
||||
|
||||
3. Communication of the Source Code
|
||||
|
||||
The Licensor may provide the Work either in its Source Code form, or as
|
||||
Executable Code. If the Work is provided as Executable Code, the Licensor
|
||||
provides in addition a machine-readable copy of the Source Code of the Work
|
||||
along with each copy of the Work that the Licensor distributes or indicates, in
|
||||
a notice following the copyright notice attached to the Work, a repository where
|
||||
the Source Code is easily and freely accessible for as long as the Licensor
|
||||
continues to distribute or communicate the Work.
|
||||
|
||||
4. Limitations on copyright
|
||||
|
||||
Nothing in this Licence is intended to deprive the Licensee of the benefits from
|
||||
any exception or limitation to the exclusive rights of the rights owners in the
|
||||
Work, of the exhaustion of those rights or of other applicable limitations
|
||||
thereto.
|
||||
|
||||
5. Obligations of the Licensee
|
||||
|
||||
The grant of the rights mentioned above is subject to some restrictions and
|
||||
obligations imposed on the Licensee. Those obligations are the following:
|
||||
|
||||
Attribution right: The Licensee shall keep intact all copyright, patent or
|
||||
trademarks notices and all notices that refer to the Licence and to the
|
||||
disclaimer of warranties. The Licensee must include a copy of such notices and a
|
||||
copy of the Licence with every copy of the Work he/she distributes or
|
||||
communicates. The Licensee must cause any Derivative Work to carry prominent
|
||||
notices stating that the Work has been modified and the date of modification.
|
||||
|
||||
Copyleft clause: If the Licensee distributes or communicates copies of the
|
||||
Original Works or Derivative Works, this Distribution or Communication will be
|
||||
done under the terms of this Licence or of a later version of this Licence
|
||||
unless the Original Work is expressly distributed only under this version of the
|
||||
Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee
|
||||
(becoming Licensor) cannot offer or impose any additional terms or conditions on
|
||||
the Work or Derivative Work that alter or restrict the terms of the Licence.
|
||||
|
||||
Compatibility clause: If the Licensee Distributes or Communicates Derivative
|
||||
Works or copies thereof based upon both the Work and another work licensed under
|
||||
a Compatible Licence, this Distribution or Communication can be done under the
|
||||
terms of this Compatible Licence. For the sake of this clause, ‘Compatible
|
||||
Licence’ refers to the licences listed in the appendix attached to this Licence.
|
||||
Should the Licensee's obligations under the Compatible Licence conflict with
|
||||
his/her obligations under this Licence, the obligations of the Compatible
|
||||
Licence shall prevail.
|
||||
|
||||
Provision of Source Code: When distributing or communicating copies of the Work,
|
||||
the Licensee will provide a machine-readable copy of the Source Code or indicate
|
||||
a repository where this Source will be easily and freely available for as long
|
||||
as the Licensee continues to distribute or communicate the Work.
|
||||
|
||||
Legal Protection: This Licence does not grant permission to use the trade names,
|
||||
trademarks, service marks, or names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the copyright notice.
|
||||
|
||||
6. Chain of Authorship
|
||||
|
||||
The original Licensor warrants that the copyright in the Original Work granted
|
||||
hereunder is owned by him/her or licensed to him/her and that he/she has the
|
||||
power and authority to grant the Licence.
|
||||
|
||||
Each Contributor warrants that the copyright in the modifications he/she brings
|
||||
to the Work are owned by him/her or licensed to him/her and that he/she has the
|
||||
power and authority to grant the Licence.
|
||||
|
||||
Each time You accept the Licence, the original Licensor and subsequent
|
||||
Contributors grant You a licence to their contributions to the Work, under the
|
||||
terms of this Licence.
|
||||
|
||||
7. Disclaimer of Warranty
|
||||
|
||||
The Work is a work in progress, which is continuously improved by numerous
|
||||
Contributors. It is not a finished work and may therefore contain defects or
|
||||
‘bugs’ inherent to this type of development.
|
||||
|
||||
For the above reason, the Work is provided under the Licence on an ‘as is’ basis
|
||||
and without warranties of any kind concerning the Work, including without
|
||||
limitation merchantability, fitness for a particular purpose, absence of defects
|
||||
or errors, accuracy, non-infringement of intellectual property rights other than
|
||||
copyright as stated in Article 6 of this Licence.
|
||||
|
||||
This disclaimer of warranty is an essential part of the Licence and a condition
|
||||
for the grant of any rights to the Work.
|
||||
|
||||
8. Disclaimer of Liability
|
||||
|
||||
Except in the cases of wilful misconduct or damages directly caused to natural
|
||||
persons, the Licensor will in no event be liable for any direct or indirect,
|
||||
material or moral, damages of any kind, arising out of the Licence or of the use
|
||||
of the Work, including without limitation, damages for loss of goodwill, work
|
||||
stoppage, computer failure or malfunction, loss of data or any commercial
|
||||
damage, even if the Licensor has been advised of the possibility of such damage.
|
||||
However, the Licensor will be liable under statutory product liability laws as
|
||||
far such laws apply to the Work.
|
||||
|
||||
9. Additional agreements
|
||||
|
||||
While distributing the Work, You may choose to conclude an additional agreement,
|
||||
defining obligations or services consistent with this Licence. However, if
|
||||
accepting obligations, You may act only on your own behalf and on your sole
|
||||
responsibility, not on behalf of the original Licensor or any other Contributor,
|
||||
and only if You agree to indemnify, defend, and hold each Contributor harmless
|
||||
for any liability incurred by, or claims asserted against such Contributor by
|
||||
the fact You have accepted any warranty or additional liability.
|
||||
|
||||
10. Acceptance of the Licence
|
||||
|
||||
The provisions of this Licence can be accepted by clicking on an icon ‘I agree’
|
||||
placed under the bottom of a window displaying the text of this Licence or by
|
||||
affirming consent in any other similar way, in accordance with the rules of
|
||||
applicable law. Clicking on that icon indicates your clear and irrevocable
|
||||
acceptance of this Licence and all of its terms and conditions.
|
||||
|
||||
Similarly, you irrevocably accept this Licence and all of its terms and
|
||||
conditions by exercising any rights granted to You by Article 2 of this Licence,
|
||||
such as the use of the Work, the creation by You of a Derivative Work or the
|
||||
Distribution or Communication by You of the Work or copies thereof.
|
||||
|
||||
11. Information to the public
|
||||
|
||||
In case of any Distribution or Communication of the Work by means of electronic
|
||||
communication by You (for example, by offering to download the Work from a
|
||||
remote location) the distribution channel or media (for example, a website) must
|
||||
at least provide to the public the information requested by the applicable law
|
||||
regarding the Licensor, the Licence and the way it may be accessible, concluded,
|
||||
stored and reproduced by the Licensee.
|
||||
|
||||
12. Termination of the Licence
|
||||
|
||||
The Licence and the rights granted hereunder will terminate automatically upon
|
||||
any breach by the Licensee of the terms of the Licence.
|
||||
|
||||
Such a termination will not terminate the licences of any person who has
|
||||
received the Work from the Licensee under the Licence, provided such persons
|
||||
remain in full compliance with the Licence.
|
||||
|
||||
13. Miscellaneous
|
||||
|
||||
Without prejudice of Article 9 above, the Licence represents the complete
|
||||
agreement between the Parties as to the Work.
|
||||
|
||||
If any provision of the Licence is invalid or unenforceable under applicable
|
||||
law, this will not affect the validity or enforceability of the Licence as a
|
||||
whole. Such provision will be construed or reformed so as necessary to make it
|
||||
valid and enforceable.
|
||||
|
||||
The European Commission may publish other linguistic versions or new versions of
|
||||
this Licence or updated versions of the Appendix, so far this is required and
|
||||
reasonable, without reducing the scope of the rights granted by the Licence. New
|
||||
versions of the Licence will be published with a unique version number.
|
||||
|
||||
All linguistic versions of this Licence, approved by the European Commission,
|
||||
have identical value. Parties can take advantage of the linguistic version of
|
||||
their choice.
|
||||
|
||||
14. Jurisdiction
|
||||
|
||||
Without prejudice to specific agreement between parties,
|
||||
|
||||
- any litigation resulting from the interpretation of this License, arising
|
||||
between the European Union institutions, bodies, offices or agencies, as a
|
||||
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
|
||||
of Justice of the European Union, as laid down in article 272 of the Treaty on
|
||||
the Functioning of the European Union,
|
||||
|
||||
- any litigation arising between other parties and resulting from the
|
||||
interpretation of this License, will be subject to the exclusive jurisdiction
|
||||
of the competent court where the Licensor resides or conducts its primary
|
||||
business.
|
||||
|
||||
15. Applicable Law
|
||||
|
||||
Without prejudice to specific agreement between parties,
|
||||
|
||||
- this Licence shall be governed by the law of the European Union Member State
|
||||
where the Licensor has his seat, resides or has his registered office,
|
||||
|
||||
- this licence shall be governed by Belgian law if the Licensor has no seat,
|
||||
residence or registered office inside a European Union Member State.
|
||||
|
||||
Appendix
|
||||
|
||||
‘Compatible Licences’ according to Article 5 EUPL are:
|
||||
|
||||
- GNU General Public License (GPL) v. 2, v. 3
|
||||
- GNU Affero General Public License (AGPL) v. 3
|
||||
- Open Software License (OSL) v. 2.1, v. 3.0
|
||||
- Eclipse Public License (EPL) v. 1.0
|
||||
- CeCILL v. 2.0, v. 2.1
|
||||
- Mozilla Public Licence (MPL) v. 2
|
||||
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
|
||||
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
|
||||
works other than software
|
||||
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
|
||||
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
|
||||
Reciprocity (LiLiQ-R+).
|
||||
|
||||
The European Commission may update this Appendix to later versions of the above
|
||||
licences without producing a new version of the EUPL, as long as they provide
|
||||
the rights granted in Article 2 of this Licence and protect the covered Source
|
||||
Code from exclusive appropriation.
|
||||
|
||||
All other changes or additions to this Appendix require the production of a new
|
||||
EUPL version.
|
||||
Copyright © 2016, Martin Herkt <lachs0r@srsfckn.biz>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
|
131
README.rst
131
README.rst
|
@ -4,21 +4,6 @@ The Null Pointer
|
|||
This is a no-bullshit file hosting and URL shortening service that also runs
|
||||
`0x0.st <https://0x0.st>`_. Use with uWSGI.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
To configure 0x0, copy ``instance/config.example.py`` to ``instance/config.py``, then edit
|
||||
it. Resonable defaults are set, but there's a couple options you'll need to change
|
||||
before running 0x0 for the first time.
|
||||
|
||||
By default, the configuration is stored in the Flask instance directory.
|
||||
Normally, this is in `./instance`, but it might be different for your system.
|
||||
For details, see
|
||||
`the Flask documentation <https://flask.palletsprojects.com/en/2.0.x/config/#instance-folders>`_.
|
||||
|
||||
To customize the home and error pages, simply create a ``templates`` directory
|
||||
in your instance directory and copy any templates you want to modify there.
|
||||
|
||||
If you are running nginx, you should use the ``X-Accel-Redirect`` header.
|
||||
To make it work, include this in your nginx config’s ``server`` block::
|
||||
|
||||
|
@ -26,70 +11,18 @@ To make it work, include this in your nginx config’s ``server`` block::
|
|||
internal;
|
||||
}
|
||||
|
||||
where ``/up`` is whatever you’ve configured as ``FHOST_STORAGE_PATH``.
|
||||
where ``/up`` is whatever you’ve configured as ``FHOST_STORAGE_PATH``
|
||||
in ``fhost.py``.
|
||||
|
||||
For all other servers, set ``FHOST_USE_X_ACCEL_REDIRECT`` to ``False`` and
|
||||
``USE_X_SENDFILE`` to ``True``, assuming your server supports this.
|
||||
Otherwise, Flask will serve the file with chunked encoding, which has several
|
||||
downsides, one of them being that range requests will not work. This is a
|
||||
problem for example when streaming media files: It won’t be possible to seek,
|
||||
and some ISOBMFF (MP4) files will not play at all.
|
||||
Otherwise, Flask will serve the file with chunked encoding, which sucks and
|
||||
should be avoided at all costs.
|
||||
|
||||
To make files expire, simply run ``FLASK_APP=fhost flask prune`` every
|
||||
now and then. You can use the provided systemd unit files for this::
|
||||
To make files expire, simply create a cronjob that runs ``cleanup.py`` every
|
||||
now and then.
|
||||
|
||||
0x0-prune.service
|
||||
0x0-prune.timer
|
||||
|
||||
Make sure to edit them to match your system configuration. In particular,
|
||||
set the user and paths in ``0x0-prune.service``.
|
||||
|
||||
Before running the service for the first time and every time you update it
|
||||
from this git repository, run ``FLASK_APP=fhost flask db upgrade``.
|
||||
|
||||
|
||||
Moderation UI
|
||||
-------------
|
||||
|
||||
.. image:: modui.webp
|
||||
:height: 300
|
||||
|
||||
0x0 features a TUI program for file moderation. With it, you can view a list
|
||||
of uploaded files, as well as extended information on them. It allows you to
|
||||
take actions like removing files temporarily or permanently, as well as
|
||||
blocking IP addresses and associated files.
|
||||
|
||||
If a sufficiently recent version of python-mpv with libmpv is present and
|
||||
your terminal supports it, you also get graphical file previews, including
|
||||
video playback. Upstream mpv currently supports sixels and the
|
||||
`kitty graphics protocol <https://sw.kovidgoyal.net/kitty/graphics-protocol/>`_.
|
||||
For this to work, set the ``MOD_PREVIEW_PROTO`` option in ``instance/config.py``.
|
||||
|
||||
Requirements:
|
||||
|
||||
* `Textual <https://textual.textualize.io/>`_
|
||||
|
||||
Optional:
|
||||
|
||||
* `python-mpv <https://github.com/jaseg/python-mpv>`_
|
||||
(graphical previews)
|
||||
* `PyAV <https://github.com/PyAV-Org/PyAV>`_
|
||||
(information on multimedia files)
|
||||
* `PyMuPDF <https://github.com/pymupdf/PyMuPDF>`_
|
||||
(previews and file information for PDF, XPS, EPUB, MOBI and FB2)
|
||||
* `libarchive-c <https://github.com/Changaco/python-libarchive-c>`_
|
||||
(archive content listing)
|
||||
|
||||
.. note::
|
||||
`Mosh <https://mosh.org/>`_ currently does not support sixels or kitty graphics.
|
||||
|
||||
.. hint::
|
||||
You may need to set the ``COLORTERM`` environment variable to
|
||||
``truecolor``.
|
||||
|
||||
.. tip::
|
||||
Using compression with SSH (``-C`` option) can significantly
|
||||
reduce the bandwidth requirements for graphics.
|
||||
Before running the service for the first time, run ``./fhost.py db upgrade``.
|
||||
|
||||
|
||||
NSFW Detection
|
||||
|
@ -100,35 +33,33 @@ neural network model. This works for images and video files and requires
|
|||
the following:
|
||||
|
||||
* Caffe Python module (built for Python 3)
|
||||
* `PyAV <https://github.com/PyAV-Org/PyAV>`_
|
||||
* ``ffmpegthumbnailer`` executable in ``$PATH``
|
||||
|
||||
|
||||
Virus Scanning
|
||||
--------------
|
||||
FAQ
|
||||
---
|
||||
|
||||
0x0 can scan its files with ClamAV’s daemon. As this can take a long time
|
||||
for larger files, this does not happen immediately but instead every time
|
||||
you run the ``vscan`` command. It is recommended to configure a systemd
|
||||
timer or cronjob to do this periodically. Examples are included::
|
||||
Q:
|
||||
Will you ever add a web interface with HTML forms?
|
||||
A:
|
||||
No. This would without a doubt make it very popular and quickly exceed
|
||||
my hosting budget unless I started crippling it.
|
||||
|
||||
0x0-vscan.service
|
||||
0x0-vscan.timer
|
||||
Q:
|
||||
What about file management? Will I be able to register an account at some
|
||||
point?
|
||||
A:
|
||||
No.
|
||||
|
||||
Remember to adjust your size limits in clamd.conf, including
|
||||
``StreamMaxLength``!
|
||||
Q:
|
||||
Why are you storing IP addresses with each uploaded file?
|
||||
A:
|
||||
This is done to make dealing with legal claims and accidental uploads
|
||||
easier, e.g. when a user requests removal of all text files uploaded from
|
||||
a certain address within a given time frame (it happens).
|
||||
|
||||
This feature requires the `clamd module <https://pypi.org/project/clamd/>`_.
|
||||
|
||||
|
||||
Network Security Considerations
|
||||
-------------------------------
|
||||
|
||||
Keep in mind that 0x0 can fetch files from URLs. This includes your local
|
||||
network! You should take precautions so that this feature cannot be abused.
|
||||
0x0 does not (yet) have a way to filter remote URLs, but on Linux, you can
|
||||
use firewall rules and/or namespaces. This is less error-prone anyway.
|
||||
|
||||
For instance, if you are using the excellent `FireHOL <https://firehol.org/>`_,
|
||||
it’s very easy to create a group on your system and use it as a condition
|
||||
in your firewall rules. You would then run the application server under that
|
||||
group.
|
||||
Q:
|
||||
Do you accept donations?
|
||||
A:
|
||||
Only if you insist. I’ve spent very little time and effort on this service
|
||||
and I don’t feel like I should be taking money for it.
|
||||
|
|
27
cleanup.py
27
cleanup.py
|
@ -1,8 +1,23 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
print("This script has been replaced!!")
|
||||
print("Instead, please run")
|
||||
print("")
|
||||
print(" $ FLASK_APP=fhost flask prune")
|
||||
print("")
|
||||
exit(1);
|
||||
import os, sys, time, datetime
|
||||
from fhost import app
|
||||
|
||||
os.chdir(os.path.dirname(sys.argv[0]))
|
||||
os.chdir(app.config["FHOST_STORAGE_PATH"])
|
||||
|
||||
files = [f for f in os.listdir(".")]
|
||||
|
||||
maxs = app.config["MAX_CONTENT_LENGTH"]
|
||||
mind = 30
|
||||
maxd = 365
|
||||
|
||||
for f in files:
|
||||
stat = os.stat(f)
|
||||
systime = time.time()
|
||||
age = datetime.timedelta(seconds = systime - stat.st_mtime).days
|
||||
|
||||
maxage = mind + (-maxd + mind) * (stat.st_size / maxs - 1) ** 3
|
||||
|
||||
if age >= maxage:
|
||||
os.remove(f)
|
||||
|
|
BIN
favicon.ico
BIN
favicon.ico
Binary file not shown.
Before Width: | Height: | Size: 22 KiB |
|
@ -1,226 +0,0 @@
|
|||
|
||||
|
||||
################################################################################
|
||||
# This is a configuration file for 0x0 / The Null Pointer #
|
||||
# #
|
||||
# The default values here are set to generally reasonable defaults, but a #
|
||||
# couple of things need your attention. Specifically, make sure you set #
|
||||
# SQLALCHEMY_DATABASE_URI. You'll also probably want to configure #
|
||||
# FHOST_USE_X_SENDFILE and FHOST_USE_X_ACCEL_REDIRECT to match your webserver. #
|
||||
# #
|
||||
# Need help, or find anything confusing? Try opening up an issue! #
|
||||
# https://git.0x0.st/mia/0x0/issues/new #
|
||||
################################################################################
|
||||
|
||||
|
||||
|
||||
# The database URL for the database 0x0 should use
|
||||
#
|
||||
# See https://docs.sqlalchemy.org/en/20/core/engines.html#backend-specific-urls
|
||||
# for help configuring these for your database.
|
||||
#
|
||||
# For small and medium servers, it's plenty sufficient to just use an sqlite
|
||||
# database. In this case, the database URI you want to use is just
|
||||
#
|
||||
# sqlite:/// + /path/to/your/database.db
|
||||
#
|
||||
# Until https://git.0x0.st/mia/0x0/issues/70 is resolved, it's recommended that
|
||||
# any sqlite databases use an absolute path, as relative paths aren't consistently
|
||||
# resolved.
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + '/path/to/database.sqlite'
|
||||
|
||||
|
||||
# The maximum allowable upload size, in bytes
|
||||
#
|
||||
# Keep in mind that this affects the expiration of files as well! The closer a
|
||||
# file is to the max content length, the less time it will last before being
|
||||
# deleted.
|
||||
MAX_CONTENT_LENGTH = 256 * 1024 * 1024 # Default: 256MiB
|
||||
|
||||
|
||||
# The maximum length of URLs we'll shorten, in characters
|
||||
#
|
||||
# If a user tries to submit a URL longer than this, we'll reject their request
|
||||
# with a 414 REQUEST URI TOO LONG.
|
||||
MAX_URL_LENGTH = 4096
|
||||
|
||||
|
||||
# The minimum and maximum amount of time we'll retain a file for
|
||||
#
|
||||
# Small files (nearing zero bytes) are stored for the longest possible expiration date,
|
||||
# while larger files (nearing MAX_CONTENT_LENGTH bytes) are stored for the shortest amount
|
||||
# of time. Values between these two extremes are interpolated with an exponential curve,
|
||||
# like the one shown on the index page.
|
||||
#
|
||||
# All times are in milliseconds. If you want all files to be stored for the same amount
|
||||
# of time, set these to the same value.
|
||||
FHOST_MIN_EXPIRATION = 30 * 24 * 60 * 60 * 1000
|
||||
FHOST_MAX_EXPIRATION = 365 * 24 * 60 * 60 * 1000
|
||||
|
||||
|
||||
# This should be detected automatically when running behind a reverse proxy, but needs
|
||||
# to be set for URL resolution to work in e.g. the moderation UI.
|
||||
# SERVER_NAME = "example.com"
|
||||
|
||||
|
||||
# Specifies which graphics protocol to use for the media previews in the moderation UI.
|
||||
# Requires pympv with libmpv >= 0.36.0 and terminal support.
|
||||
# Available choices are "sixel" and "kitty".
|
||||
# MOD_PREVIEW_PROTO = "sixel"
|
||||
|
||||
|
||||
# Use the X-SENDFILE header to speed up serving files w/ compatible webservers
|
||||
#
|
||||
# Some webservers can be configured use the X-Sendfile header to handle sending
|
||||
# large files on behalf of the application. If your server is setup to do
|
||||
# this, set this variable to True
|
||||
USE_X_SENDFILE = False
|
||||
|
||||
|
||||
# Use X-Accel-Redirect to speed up serving files w/ compatible webservers
|
||||
#
|
||||
# Other webservers, like nginx and Caddy, use the X-Accel-Redirect header to
|
||||
# accomplish a very similar thing to X-Sendfile (above). If your webserver is
|
||||
# configured to do this, set this variable to True
|
||||
#
|
||||
# Note: It's recommended that you use either X-Sendfile or X-Accel-Redirect
|
||||
# when you deploy in production.
|
||||
FHOST_USE_X_ACCEL_REDIRECT = True # expect nginx by default
|
||||
|
||||
|
||||
# The directory that 0x0 should store uploaded files in
|
||||
#
|
||||
# Whenever a file is uploaded to 0x0, we store it here! Relative paths are
|
||||
# resolved relative to the working directory that 0x0 is being run from.
|
||||
FHOST_STORAGE_PATH = "up"
|
||||
|
||||
|
||||
# The maximum acceptable user-specified file extension
|
||||
#
|
||||
# When a user uploads a file, in most cases, we keep the file extension they
|
||||
# provide. But! If the specified file extension is longer than
|
||||
# FHOST_MAX_EXT_LENGTH, we truncate it. So if a user tries to upload the file
|
||||
# "myfile.withareallongext", but FHOST_MAX_EXT_LENGTH is set to 9, then the
|
||||
# extension that we keep is ".withareal"
|
||||
FHOST_MAX_EXT_LENGTH = 9
|
||||
|
||||
|
||||
# The number of bytes used for "secret" URLs
|
||||
#
|
||||
# When a user uploads a file with the "secret" option, 0x0 generates a string
|
||||
# from this many bytes of random data. It is base64-encoded, so on average
|
||||
# each byte results in approximately 1.3 characters.
|
||||
FHOST_SECRET_BYTES = 16
|
||||
|
||||
# A list of filetypes to use when the uploader doesn't specify one
|
||||
#
|
||||
# When a user uploads a file with no file extension, we try to find an extension that
|
||||
# works for that file. This configuration option is the first thing that we check. If
|
||||
# the type of a file without an extension is in this dict, then it'll be used as the file
|
||||
# extension for that file. Otherwise, we try to pick something sensible from libmagic's
|
||||
# database.
|
||||
#
|
||||
# For example, if the user uploads "myfile" with no extension, and the file is a jpeg
|
||||
# image, the file will get a URL like "eAa.jpg"
|
||||
#
|
||||
# For a list of MIME types you can use in this list, check
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
|
||||
FHOST_EXT_OVERRIDE = {
|
||||
"audio/flac" : ".flac",
|
||||
"image/gif" : ".gif",
|
||||
"image/jpeg" : ".jpg",
|
||||
"image/png" : ".png",
|
||||
"image/svg+xml" : ".svg",
|
||||
"video/webm" : ".webm",
|
||||
"video/x-matroska" : ".mkv",
|
||||
"application/octet-stream" : ".bin",
|
||||
"text/plain" : ".log",
|
||||
"text/plain" : ".txt",
|
||||
"text/x-diff" : ".diff",
|
||||
}
|
||||
|
||||
|
||||
# Control which files aren't allowed to be uploaded
|
||||
#
|
||||
# Certain kinds of files are never accepted. If the file claims to be one of
|
||||
# these types of files, or if we look at the contents of the file and it looks
|
||||
# like one of these filetypes, then we reject the file outright with a 415
|
||||
# UNSUPPORTED MEDIA EXCEPTION
|
||||
FHOST_MIME_BLACKLIST = [
|
||||
"application/x-dosexec",
|
||||
"application/java-archive",
|
||||
"application/java-vm"
|
||||
]
|
||||
|
||||
|
||||
# A list of IP addresses which are blacklisted from uploading files
|
||||
#
|
||||
# Can be set to the path of a file with an IP address on each line. The file
|
||||
# can also include comment lines using a pound sign (#). Paths are resolved
|
||||
# relative to the instance/ directory.
|
||||
#
|
||||
# If this is set to None, then no IP blacklist will be consulted.
|
||||
FHOST_UPLOAD_BLACKLIST = None
|
||||
|
||||
|
||||
# Enables support for detecting NSFW images
|
||||
#
|
||||
# Consult README.md for additional dependencies before setting to True
|
||||
NSFW_DETECT = False
|
||||
|
||||
|
||||
# The cutoff for when an image is considered NFSW
|
||||
#
|
||||
# When the NSFW detection algorithm generates an output higher than this
|
||||
# number, an image is considered to be NSFW. NSFW images aren't declined, but
|
||||
# are marked as NSFW.
|
||||
#
|
||||
# If NSFW_DETECT is set to False, then this has no effect.
|
||||
NSFW_THRESHOLD = 0.608
|
||||
|
||||
|
||||
# If you want to scan files for viruses using ClamAV, specify the socket used
|
||||
# for connections here. You will need the clamd module.
|
||||
# Since this can take a very long time on larger files, it is not done
|
||||
# immediately but every time you run the vscan command. It is recommended to
|
||||
# configure a systemd timer or cronjob to do this periodically.
|
||||
# Remember to adjust your size limits in clamd.conf, including StreamMaxLength!
|
||||
#
|
||||
# Example:
|
||||
# from clamd import ClamdUnixSocket
|
||||
# VSCAN_SOCKET = ClamdUnixSocket("/run/clamav/clamd-socket")
|
||||
|
||||
# This is the directory that files flagged as malicious are moved to.
|
||||
# Relative paths are resolved relative to the working directory
|
||||
# of the 0x0 process.
|
||||
VSCAN_QUARANTINE_PATH = "quarantine"
|
||||
|
||||
# Since updated virus definitions might catch some files that were previously
|
||||
# reported as clean, you may want to rescan old files periodically.
|
||||
# Set this to a datetime.timedelta to specify the frequency, or None to
|
||||
# disable rescanning.
|
||||
from datetime import timedelta
|
||||
VSCAN_INTERVAL = timedelta(days=7)
|
||||
|
||||
# Some files flagged by ClamAV are usually not malicious, especially if the
|
||||
# DetectPUA option is enabled in clamd.conf. This is a list of signatures
|
||||
# that will be ignored.
|
||||
VSCAN_IGNORE = [
|
||||
"Eicar-Test-Signature",
|
||||
"PUA.Win.Packer.XmMusicFile",
|
||||
]
|
||||
|
||||
# A list of all characters which can appear in a URL
|
||||
#
|
||||
# If this list is too short, then URLs can very quickly become long.
|
||||
# Generally, the default value for this should work for basically all usecases.
|
||||
URL_ALPHABET = "DEQhd2uFteibPwq0SWBInTpA_jcZL5GKz3YCR14Ulk87Jors9vNHgfaOmMXy6Vx-"
|
||||
|
||||
|
||||
#################################################################################
|
||||
# CONGRATULATIONS! You made it all the way through! #
|
||||
# If you want to go even further to customize your instance, try checking out #
|
||||
# the templates in the templates/ directory to customize your landing page, 404 #
|
||||
# page, and other error pages. #
|
||||
#################################################################################
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#99f2b9">
|
||||
<link rel="stylesheet" href="https://tilde.team/css/hacker.css">
|
||||
<title>ttm.sh</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<h1>THE NULL POINTER</h1>
|
||||
<hr>
|
||||
|
||||
{% set fhost_url = url_for("fhost", _external=True).rstrip("/") %}
|
||||
|
||||
<h2>USAGE</h2>
|
||||
<p>HTTP POST files here:</p>
|
||||
<pre>curl -F'file=@yourfile.png' {{ fhost_url }}</pre>
|
||||
<p>You can also POST remote URLs:</p>
|
||||
<pre>curl -F'url=http://example.com/image.jpg' {{ fhost_url }}</pre>
|
||||
|
||||
<p><em>if you want a nice wrapper, try <a href="https://tildegit.org/tomasino/pb">~tomasino's pb</a></em></p>
|
||||
<p>here's our <a href="https://ttm.sh/sharex.json">sharex config</a></p>
|
||||
|
||||
<h2>RETENTION AND GUIDELINES</h2>
|
||||
<p>File URLs are valid for at least 30 days and up to a year (see below).
|
||||
Shortened URLs do not expire.</p>
|
||||
{% set max_size = config["MAX_CONTENT_LENGTH"] | filesizeformat(True) %}
|
||||
<p>Maximum file size: <strong>{{ max_size }}</strong></p>
|
||||
<p>Not allowed: <em>{{ config["FHOST_MIME_BLACKLIST"] | join(", ") }}</em></p>
|
||||
|
||||
<pre>
|
||||
retention = min_age + (-max_age + min_age) * pow((file_size / max_size - 1), 3)
|
||||
|
||||
days
|
||||
365 | \\
|
||||
| \\
|
||||
| \\
|
||||
| \\
|
||||
| \\
|
||||
| \\
|
||||
| ..
|
||||
| \\
|
||||
197.5 | ----------..-------------------------------------------
|
||||
| ..
|
||||
| \\
|
||||
| ..
|
||||
| ...
|
||||
| ..
|
||||
| ...
|
||||
| ....
|
||||
| ......
|
||||
30 | ....................
|
||||
0{{ ((config["MAX_CONTENT_LENGTH"] / 2) | filesizeformat(True)).split(" ")[0].rjust(27) }}{{ max_size.split(" ")[0].rjust(27) }}
|
||||
{{ max_size.split(" ")[1].rjust(54) }}
|
||||
</pre>
|
||||
|
||||
<h2>CONTACT</h2>
|
||||
|
||||
<p>
|
||||
If you would like to request permanent deletion, please contact <code>ben</code> or
|
||||
<code>khuxkm</code> on <a href="https://tilde.chat">tilde.chat</a> IRC, or send an
|
||||
email to sudoers@tilde.team with the id of the file to be deleted.
|
||||
</p>
|
||||
|
||||
<p>Please allow up to 24 hours for a response.</p>
|
||||
|
||||
<h2>UPLOAD DIRECTLY</h2>
|
||||
|
||||
<form action="{{ fhost_url }}" method="POST" enctype="multipart/form-data">
|
||||
<label for="file">File:</label>
|
||||
<input class="form-control" type="file" name="file"><br><br>
|
||||
<input class="form-control" type="submit" value="Submit">
|
||||
</form>
|
||||
|
||||
<footer class="text-center"><a href="https://tildegit.org/tildeverse/ttm.sh">source here</a></footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,26 +0,0 @@
|
|||
"""add file management token
|
||||
|
||||
Revision ID: 0659d7b9eea8
|
||||
Revises: 939a08e1d6e5
|
||||
Create Date: 2022-11-30 01:06:53.362973
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0659d7b9eea8'
|
||||
down_revision = '939a08e1d6e5'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('file', sa.Column('mgmt_token', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('file', 'mgmt_token')
|
||||
# ### end Alembic commands ###
|
|
@ -1,46 +0,0 @@
|
|||
"""add file size field
|
||||
|
||||
Revision ID: 30bfe33aa328
|
||||
Revises: 5cee97aab219
|
||||
Create Date: 2022-12-13 22:32:12.242394
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '30bfe33aa328'
|
||||
down_revision = '5cee97aab219'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
from flask import current_app
|
||||
from pathlib import Path
|
||||
|
||||
Base = automap_base()
|
||||
|
||||
def upgrade():
|
||||
op.add_column('file', sa.Column('size', sa.BigInteger(), nullable=True))
|
||||
bind = op.get_bind()
|
||||
Base.prepare(autoload_with=bind)
|
||||
File = Base.classes.file
|
||||
session = Session(bind=bind)
|
||||
|
||||
storage = Path(current_app.config["FHOST_STORAGE_PATH"])
|
||||
|
||||
updates = []
|
||||
files = session.scalars(sa.select(File).where(sa.not_(File.removed)))
|
||||
for f in files:
|
||||
p = storage / f.sha256
|
||||
if p.is_file():
|
||||
updates.append({
|
||||
"id" : f.id,
|
||||
"size" : p.stat().st_size
|
||||
})
|
||||
|
||||
session.bulk_update_mappings(File, updates)
|
||||
session.commit()
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('file', 'size')
|
|
@ -1,26 +0,0 @@
|
|||
"""add date of last virus scan
|
||||
|
||||
Revision ID: 5cee97aab219
|
||||
Revises: e2e816056589
|
||||
Create Date: 2022-12-10 16:39:56.388259
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5cee97aab219'
|
||||
down_revision = 'e2e816056589'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('file', sa.Column('last_vscan', sa.DateTime(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('file', 'last_vscan')
|
||||
# ### end Alembic commands ###
|
|
@ -1,86 +0,0 @@
|
|||
"""add file expirations
|
||||
|
||||
Revision ID: 939a08e1d6e5
|
||||
Revises: 7e246705da6a
|
||||
Create Date: 2022-11-22 12:16:32.517184
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '939a08e1d6e5'
|
||||
down_revision = '7e246705da6a'
|
||||
|
||||
from alembic import op
|
||||
from flask import current_app
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from pathlib import Path
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.automap import automap_base
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
""" For a file of a given size, determine the largest allowed lifespan of that file
|
||||
|
||||
Based on the current app's configuration: Specifically, the MAX_CONTENT_LENGTH, as well
|
||||
as FHOST_{MIN,MAX}_EXPIRATION.
|
||||
|
||||
This lifespan may be shortened by a user's request, but no files should be allowed to
|
||||
expire at a point after this number.
|
||||
|
||||
Value returned is a duration in milliseconds.
|
||||
"""
|
||||
def get_max_lifespan(filesize: int) -> int:
|
||||
min_exp = current_app.config.get("FHOST_MIN_EXPIRATION", 30 * 24 * 60 * 60 * 1000)
|
||||
max_exp = current_app.config.get("FHOST_MAX_EXPIRATION", 365 * 24 * 60 * 60 * 1000)
|
||||
max_size = current_app.config.get("MAX_CONTENT_LENGTH", 256 * 1024 * 1024)
|
||||
return min_exp + int((-max_exp + min_exp) * (filesize / max_size - 1) ** 3)
|
||||
|
||||
Base = automap_base()
|
||||
|
||||
def upgrade():
|
||||
op.add_column('file', sa.Column('expiration', sa.BigInteger()))
|
||||
|
||||
bind = op.get_bind()
|
||||
Base.prepare(autoload_with=bind)
|
||||
File = Base.classes.file
|
||||
session = Session(bind=bind)
|
||||
|
||||
storage = Path(current_app.config["FHOST_STORAGE_PATH"])
|
||||
current_time = time.time() * 1000;
|
||||
|
||||
# List of file hashes which have not expired yet
|
||||
# This could get really big for some servers
|
||||
try:
|
||||
unexpired_files = os.listdir(storage)
|
||||
except FileNotFoundError:
|
||||
return # There are no currently unexpired files
|
||||
|
||||
# Calculate an expiration date for all existing files
|
||||
|
||||
q = session.scalars(
|
||||
sa.select(File)
|
||||
.where(
|
||||
sa.not_(File.removed)
|
||||
)
|
||||
)
|
||||
updates = [] # We coalesce updates to the database here
|
||||
|
||||
# SQLite has a hard limit on the number of variables so we
|
||||
# need to do this the slow way
|
||||
files = [f for f in q if f.sha256 in unexpired_files]
|
||||
|
||||
for file in files:
|
||||
file_path = storage / file.sha256
|
||||
stat = os.stat(file_path)
|
||||
max_age = get_max_lifespan(stat.st_size) # How long the file is allowed to live, in ms
|
||||
file_birth = stat.st_mtime * 1000 # When the file was created, in ms
|
||||
updates.append({'id': file.id, 'expiration': int(file_birth + max_age)})
|
||||
|
||||
# Apply coalesced updates
|
||||
session.bulk_update_mappings(File, updates)
|
||||
session.commit()
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('file', 'expiration')
|
|
@ -1,30 +0,0 @@
|
|||
"""Store user agent string with files
|
||||
|
||||
Revision ID: dd0766afb7d2
|
||||
Revises: 30bfe33aa328
|
||||
Create Date: 2023-03-29 07:18:49.113200
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'dd0766afb7d2'
|
||||
down_revision = '30bfe33aa328'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('file', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('ua', sa.UnicodeText(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('file', schema=None) as batch_op:
|
||||
batch_op.drop_column('ua')
|
||||
|
||||
# ### end Alembic commands ###
|
|
@ -1,26 +0,0 @@
|
|||
"""add URL secret
|
||||
|
||||
Revision ID: e2e816056589
|
||||
Revises: 0659d7b9eea8
|
||||
Create Date: 2022-12-01 02:16:15.976864
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e2e816056589'
|
||||
down_revision = '0659d7b9eea8'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('file', sa.Column('secret', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('file', 'secret')
|
||||
# ### end Alembic commands ###
|
56
mod.css
56
mod.css
|
@ -1,56 +0,0 @@
|
|||
#ftable {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
#infopane {
|
||||
width: 50%;
|
||||
outline-top: hkey $primary;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
#finfo {
|
||||
background: $boost;
|
||||
height: 12;
|
||||
width: 1fr;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
#mpv {
|
||||
display: none;
|
||||
height: 20%;
|
||||
width: 1fr;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#ftextlog {
|
||||
height: 1fr;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
#filter_container {
|
||||
height: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#filter_label {
|
||||
content-align: right middle;
|
||||
height: 1fr;
|
||||
width: 20%;
|
||||
margin: 0 1 0 2;
|
||||
}
|
||||
|
||||
#filter_input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
Notification {
|
||||
dock: bottom;
|
||||
layer: notification;
|
||||
width: auto;
|
||||
margin: 2 4;
|
||||
padding: 1 2;
|
||||
background: $background;
|
||||
color: $text;
|
||||
height: auto;
|
||||
|
||||
}
|
283
mod.py
283
mod.py
|
@ -1,283 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from itertools import zip_longest
|
||||
from sys import stdout
|
||||
import time
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import DataTable, Header, Footer, Log, Static, Input
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import Screen
|
||||
from textual import log
|
||||
from rich.text import Text
|
||||
from jinja2.filters import do_filesizeformat
|
||||
|
||||
from fhost import db, File, su, app as fhost_app, in_upload_bl
|
||||
from modui import *
|
||||
|
||||
fhost_app.app_context().push()
|
||||
|
||||
class NullptrMod(Screen):
|
||||
BINDINGS = [
|
||||
("q", "quit_app", "Quit"),
|
||||
("f1", "filter(1, 'Lookup name:')", "Lookup name"),
|
||||
("f2", "filter(2, 'Filter IP address:')", "Filter IP"),
|
||||
("f3", "filter(3, 'Filter MIME Type:')", "Filter MIME"),
|
||||
("f4", "filter(4, 'Filter extension:')", "Filter Ext."),
|
||||
("f5", "refresh", "Refresh"),
|
||||
("f6", "filter_clear", "Clear filter"),
|
||||
("f7", "filter(5, 'Filter user agent:')", "Filter UA"),
|
||||
("r", "remove_file(False)", "Remove file"),
|
||||
("ctrl+r", "remove_file(True)", "Ban file"),
|
||||
("p", "ban_ip(False)", "Ban IP"),
|
||||
("ctrl+p", "ban_ip(True)", "Nuke IP"),
|
||||
]
|
||||
|
||||
async def action_quit_app(self):
|
||||
self.mpvw.shutdown()
|
||||
await self.app.action_quit()
|
||||
|
||||
def action_refresh(self):
|
||||
ftable = self.query_one("#ftable")
|
||||
ftable.watch_query(None, None)
|
||||
|
||||
def action_filter_clear(self):
|
||||
self.query_one("#filter_container").display = False
|
||||
ftable = self.query_one("#ftable")
|
||||
ftable.focus()
|
||||
ftable.query = ftable.base_query
|
||||
|
||||
def action_filter(self, fcol: int, label: str):
|
||||
self.query_one("#filter_label").update(label)
|
||||
finput = self.query_one("#filter_input")
|
||||
self.filter_col = fcol
|
||||
self.query_one("#filter_container").display = True
|
||||
finput.focus()
|
||||
self._refresh_layout()
|
||||
|
||||
if self.current_file:
|
||||
match fcol:
|
||||
case 1: finput.value = ""
|
||||
case 2: finput.value = self.current_file.addr
|
||||
case 3: finput.value = self.current_file.mime
|
||||
case 4: finput.value = self.current_file.ext
|
||||
case 5: finput.value = self.current_file.ua or ""
|
||||
|
||||
def on_input_submitted(self, message: Input.Submitted) -> None:
|
||||
self.query_one("#filter_container").display = False
|
||||
ftable = self.query_one("#ftable")
|
||||
ftable.focus()
|
||||
|
||||
if len(message.value):
|
||||
match self.filter_col:
|
||||
case 1:
|
||||
try: ftable.query = ftable.base_query.filter(File.id == su.debase(message.value))
|
||||
except ValueError: pass
|
||||
case 2: ftable.query = ftable.base_query.filter(File.addr.like(message.value))
|
||||
case 3: ftable.query = ftable.base_query.filter(File.mime.like(message.value))
|
||||
case 4: ftable.query = ftable.base_query.filter(File.ext.like(message.value))
|
||||
case 5: ftable.query = ftable.base_query.filter(File.ua.like(message.value))
|
||||
else:
|
||||
ftable.query = ftable.base_query
|
||||
|
||||
def action_remove_file(self, permanent: bool) -> None:
|
||||
if self.current_file:
|
||||
self.current_file.delete(permanent)
|
||||
db.session.commit()
|
||||
self.mount(Notification(f"{'Banned' if permanent else 'Removed'} file {self.current_file.getname()}"))
|
||||
self.action_refresh()
|
||||
|
||||
def action_ban_ip(self, nuke: bool) -> None:
|
||||
if self.current_file:
|
||||
if not fhost_app.config["FHOST_UPLOAD_BLACKLIST"]:
|
||||
self.mount(Notification("Failed: FHOST_UPLOAD_BLACKLIST not set!"))
|
||||
return
|
||||
else:
|
||||
if in_upload_bl(self.current_file.addr):
|
||||
txt = f"{self.current_file.addr} is already banned"
|
||||
else:
|
||||
with fhost_app.open_instance_resource(fhost_app.config["FHOST_UPLOAD_BLACKLIST"], "a") as bl:
|
||||
print(self.current_file.addr.lstrip("::ffff:"), file=bl)
|
||||
txt = f"Banned {self.current_file.addr}"
|
||||
|
||||
if nuke:
|
||||
tsize = 0
|
||||
trm = 0
|
||||
for f in File.query.filter(File.addr == self.current_file.addr):
|
||||
if f.getpath().is_file():
|
||||
tsize += f.size or f.getpath().stat().st_size
|
||||
trm += 1
|
||||
f.delete(True)
|
||||
db.session.commit()
|
||||
txt += f", removed {trm} {'files' if trm != 1 else 'file'} totaling {do_filesizeformat(tsize, True)}"
|
||||
self.mount(Notification(txt))
|
||||
self._refresh_layout()
|
||||
ftable = self.query_one("#ftable")
|
||||
ftable.watch_query(None, None)
|
||||
|
||||
def on_update(self) -> None:
|
||||
stdout.write("\033[?25l")
|
||||
stdout.flush()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
FileTable(id="ftable", zebra_stripes=True),
|
||||
Vertical(
|
||||
DataTable(id="finfo", show_header=False),
|
||||
MpvWidget(id="mpv"),
|
||||
Log(id="ftextlog"),
|
||||
id="infopane"))
|
||||
yield Horizontal(Static("Filter:", id="filter_label"), Input(id="filter_input"), id="filter_container")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.current_file = None
|
||||
|
||||
self.ftable = self.query_one("#ftable")
|
||||
self.ftable.focus()
|
||||
|
||||
self.finfo = self.query_one("#finfo")
|
||||
self.finfo.add_columns("key", "value")
|
||||
|
||||
self.mpvw = self.query_one("#mpv")
|
||||
self.ftlog = self.query_one("#ftextlog")
|
||||
|
||||
self.mimehandler = mime.MIMEHandler()
|
||||
self.mimehandler.register(mime.MIMECategory.Archive, self.handle_libarchive)
|
||||
self.mimehandler.register(mime.MIMECategory.Text, self.handle_text)
|
||||
self.mimehandler.register(mime.MIMECategory.AV, self.handle_mpv)
|
||||
self.mimehandler.register(mime.MIMECategory.Document, self.handle_mupdf)
|
||||
self.mimehandler.register(mime.MIMECategory.Fallback, self.handle_libarchive)
|
||||
self.mimehandler.register(mime.MIMECategory.Fallback, self.handle_mpv)
|
||||
self.mimehandler.register(mime.MIMECategory.Fallback, self.handle_raw)
|
||||
|
||||
def handle_libarchive(self, cat):
|
||||
import libarchive
|
||||
with libarchive.file_reader(str(self.current_file.getpath())) as a:
|
||||
self.ftlog.write("\n".join(e.path for e in a))
|
||||
return True
|
||||
|
||||
def handle_text(self, cat):
|
||||
with open(self.current_file.getpath(), "r") as sf:
|
||||
data = sf.read(1000000).replace("\033","")
|
||||
self.ftlog.write(data)
|
||||
return True
|
||||
|
||||
def handle_mupdf(self, cat):
|
||||
import fitz
|
||||
with fitz.open(self.current_file.getpath(),
|
||||
filetype=self.current_file.ext.lstrip(".")) as doc:
|
||||
p = doc.load_page(0)
|
||||
pix = p.get_pixmap(dpi=72)
|
||||
imgdata = pix.tobytes("ppm").hex()
|
||||
|
||||
self.mpvw.styles.height = "40%"
|
||||
self.mpvw.start_mpv("hex://" + imgdata, 0)
|
||||
|
||||
self.ftlog.write(Text.from_markup(f"[bold]Pages:[/bold] {doc.page_count}"))
|
||||
self.ftlog.write(Text.from_markup("[bold]Metadata:[/bold]"))
|
||||
for k, v in doc.metadata.items():
|
||||
self.ftlog.write(Text.from_markup(f" [bold]{k}:[/bold] {v}"))
|
||||
toc = doc.get_toc()
|
||||
if len(toc):
|
||||
self.ftlog.write(Text.from_markup("[bold]TOC:[/bold]"))
|
||||
for lvl, title, page in toc:
|
||||
self.ftlog.write(f"{' ' * lvl} {page}: {title}")
|
||||
return True
|
||||
|
||||
def handle_mpv(self, cat):
|
||||
if cat == mime.MIMECategory.AV or self.current_file.nsfw_score >= 0:
|
||||
self.mpvw.styles.height = "20%"
|
||||
self.mpvw.start_mpv(str(self.current_file.getpath()), 0)
|
||||
|
||||
import av
|
||||
with av.open(str(self.current_file.getpath())) as c:
|
||||
self.ftlog.write(Text("Format:", style="bold"))
|
||||
self.ftlog.write(f" {c.format.long_name}")
|
||||
if len(c.metadata):
|
||||
self.ftlog.write(Text("Metadata:", style="bold"))
|
||||
for k, v in c.metadata.items():
|
||||
self.ftlog.write(f" {k}: {v}")
|
||||
for s in c.streams:
|
||||
self.ftlog.write(Text(f"Stream {s.index}:", style="bold"))
|
||||
self.ftlog.write(f" Type: {s.type}")
|
||||
if s.base_rate:
|
||||
self.ftlog.write(f" Frame rate: {s.base_rate}")
|
||||
if len(s.metadata):
|
||||
self.ftlog.write(Text(" Metadata:", style="bold"))
|
||||
for k, v in s.metadata.items():
|
||||
self.ftlog.write(f" {k}: {v}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def handle_raw(self, cat):
|
||||
def hexdump(binf, length):
|
||||
def fmt(s):
|
||||
if isinstance(s, str):
|
||||
c = chr(int(s, 16))
|
||||
else:
|
||||
c = chr(s)
|
||||
s = c
|
||||
if c.isalpha(): return f"\0[chartreuse1]{s}\0[/chartreuse1]"
|
||||
if c.isdigit(): return f"\0[gold1]{s}\0[/gold1]"
|
||||
if not c.isprintable():
|
||||
g = "grey50" if c == "\0" else "cadet_blue"
|
||||
return f"\0[{g}]{s if len(s) == 2 else '.'}\0[/{g}]"
|
||||
return s
|
||||
return Text.from_markup("\n".join(f"{' '.join(map(fmt, map(''.join, zip(*[iter(c.hex())] * 2))))}"
|
||||
f"{' ' * (16 - len(c))}"
|
||||
f" {''.join(map(fmt, c))}"
|
||||
for c in map(lambda x: bytes([n for n in x if n != None]),
|
||||
zip_longest(*[iter(binf.read(min(length, 16 * 10)))] * 16))))
|
||||
|
||||
with open(self.current_file.getpath(), "rb") as binf:
|
||||
self.ftlog.write(hexdump(binf, self.current_file.size))
|
||||
if self.current_file.size > 16*10*2:
|
||||
binf.seek(self.current_file.size-16*10)
|
||||
self.ftlog.write(" [...] ".center(64, '─'))
|
||||
self.ftlog.write(hexdump(binf, self.current_file.size - binf.tell()))
|
||||
|
||||
return True
|
||||
|
||||
def on_file_table_selected(self, message: FileTable.Selected) -> None:
|
||||
f = message.file
|
||||
self.current_file = f
|
||||
self.finfo.clear()
|
||||
self.finfo.add_rows([
|
||||
("ID:", str(f.id)),
|
||||
("File name:", f.getname()),
|
||||
("URL:", f.geturl() if fhost_app.config["SERVER_NAME"] else "⚠ Set SERVER_NAME in config.py to display"),
|
||||
("File size:", do_filesizeformat(f.size, True)),
|
||||
("MIME type:", f.mime),
|
||||
("SHA256 checksum:", f.sha256),
|
||||
("Uploaded by:", Text(f.addr)),
|
||||
("User agent:", Text(f.ua or "")),
|
||||
("Management token:", f.mgmt_token),
|
||||
("Secret:", f.secret),
|
||||
("Is NSFW:", ("Yes" if f.is_nsfw else "No") + (f" (Score: {f.nsfw_score:0.4f})" if f.nsfw_score else " (Not scanned)")),
|
||||
("Is banned:", "Yes" if f.removed else "No"),
|
||||
("Expires:", time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(File.get_expiration(f.expiration, f.size)/1000)))
|
||||
])
|
||||
|
||||
self.mpvw.stop_mpv(True)
|
||||
self.ftlog.remove()
|
||||
self.query_one("#infopane").mount(TextLog(id="ftextlog"))
|
||||
self.ftlog = self.query_one("#ftextlog")
|
||||
|
||||
if f.getpath().is_file():
|
||||
self.mimehandler.handle(f.mime, f.ext)
|
||||
self.ftlog.scroll_home(animate=False)
|
||||
|
||||
class NullptrModApp(App):
|
||||
CSS_PATH = "mod.css"
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.title = "0x0 File Moderation Interface"
|
||||
self.main_screen = NullptrMod()
|
||||
self.install_screen(self.main_screen, name="main")
|
||||
self.push_screen("main")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = NullptrModApp()
|
||||
app.run()
|
BIN
modui.webp
BIN
modui.webp
Binary file not shown.
Before Width: | Height: | Size: 339 KiB |
|
@ -1,3 +0,0 @@
|
|||
from .filetable import FileTable
|
||||
from .notification import Notification
|
||||
from .mpvwidget import MpvWidget
|
|
@ -1,72 +0,0 @@
|
|||
from textual.widgets import DataTable, Static
|
||||
from textual.reactive import Reactive
|
||||
from textual.message import Message, MessageTarget
|
||||
from textual import events, log
|
||||
from jinja2.filters import do_filesizeformat
|
||||
|
||||
from fhost import File
|
||||
from modui import mime
|
||||
|
||||
class FileTable(DataTable):
|
||||
query = Reactive(None)
|
||||
order_col = Reactive(0)
|
||||
order_desc = Reactive(True)
|
||||
limit = 10000
|
||||
colmap = [File.id, File.removed, File.nsfw_score, None, File.ext, File.size, File.mime]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.add_columns("#", "☣️", "🔞", "📂", "name", "size", "mime")
|
||||
self.base_query = File.query.filter(File.size != None)
|
||||
self.query = self.base_query
|
||||
|
||||
class Selected(Message):
|
||||
def __init__(self, sender: MessageTarget, f: File) -> None:
|
||||
self.file = f
|
||||
super().__init__(sender)
|
||||
|
||||
def watch_order_col(self, old, value) -> None:
|
||||
self.watch_query(None, None)
|
||||
|
||||
def watch_order_desc(self, old, value) -> None:
|
||||
self.watch_query(None, None)
|
||||
|
||||
def watch_query(self, old, value) -> None:
|
||||
def fmt_file(f: File) -> tuple:
|
||||
return (
|
||||
str(f.id),
|
||||
"🔴" if f.removed else " ",
|
||||
"🚩" if f.is_nsfw else " ",
|
||||
"👻" if not f.getpath().is_file() else " ",
|
||||
f.getname(),
|
||||
do_filesizeformat(f.size, True),
|
||||
f"{mime.mimemoji.get(f.mime.split('/')[0], mime.mimemoji.get(f.mime)) or ' '} " + f.mime,
|
||||
)
|
||||
|
||||
if (self.query):
|
||||
self.clear()
|
||||
order = FileTable.colmap[self.order_col]
|
||||
q = self.query
|
||||
if order: q = q.order_by(order.desc() if self.order_desc else order, File.id)
|
||||
self.add_rows(map(fmt_file, q.limit(self.limit)))
|
||||
|
||||
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
|
||||
region = self._get_cell_region(self.cursor_row, 0)
|
||||
spacing = self._get_cell_border()
|
||||
self.scroll_to_region(region, animate=animate, spacing=spacing)
|
||||
|
||||
async def watch_cursor_cell(self, old, value) -> None:
|
||||
super().watch_cursor_cell(old, value)
|
||||
if value[0] < len(self.data) and value[0] >= 0:
|
||||
f = File.query.get(int(self.data[value[0]][0]))
|
||||
await self.emit(self.Selected(self, f))
|
||||
|
||||
def on_click(self, event: events.Click) -> None:
|
||||
super().on_click(event)
|
||||
meta = self.get_style_at(event.x, event.y).meta
|
||||
if meta:
|
||||
if meta["row"] == -1:
|
||||
qi = FileTable.colmap[meta["column"]]
|
||||
if meta["column"] == self.order_col:
|
||||
self.order_desc = not self.order_desc
|
||||
self.order_col = meta["column"]
|
126
modui/mime.py
126
modui/mime.py
|
@ -1,126 +0,0 @@
|
|||
from enum import Enum
|
||||
from textual import log
|
||||
|
||||
mimemoji = {
|
||||
"audio" : "🔈",
|
||||
"video" : "🎞",
|
||||
"text" : "📄",
|
||||
"image" : "🖼",
|
||||
"application/zip" : "🗜️",
|
||||
"application/x-zip-compressed" : "🗜️",
|
||||
"application/x-tar" : "🗄",
|
||||
"application/x-cpio" : "🗄",
|
||||
"application/x-xz" : "🗜️",
|
||||
"application/x-7z-compressed" : "🗜️",
|
||||
"application/gzip" : "🗜️",
|
||||
"application/zstd" : "🗜️",
|
||||
"application/x-rar" : "🗜️",
|
||||
"application/x-rar-compressed" : "🗜️",
|
||||
"application/vnd.ms-cab-compressed" : "🗜️",
|
||||
"application/x-bzip2" : "🗜️",
|
||||
"application/x-lzip" : "🗜️",
|
||||
"application/x-iso9660-image" : "💿",
|
||||
"application/pdf" : "📕",
|
||||
"application/epub+zip" : "📕",
|
||||
"application/mxf" : "🎞",
|
||||
"application/vnd.android.package-archive" : "📦",
|
||||
"application/vnd.debian.binary-package" : "📦",
|
||||
"application/x-rpm" : "📦",
|
||||
"application/x-dosexec" : "⚙",
|
||||
"application/x-execuftable" : "⚙",
|
||||
"application/x-sharedlib" : "⚙",
|
||||
"application/java-archive" : "☕",
|
||||
"application/x-qemu-disk" : "🖴",
|
||||
"application/pgp-encrypted" : "🔏",
|
||||
}
|
||||
|
||||
MIMECategory = Enum("MIMECategory",
|
||||
["Archive", "Text", "AV", "Document", "Fallback"]
|
||||
)
|
||||
|
||||
class MIMEHandler:
|
||||
def __init__(self):
|
||||
self.handlers = {
|
||||
MIMECategory.Archive : [[
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/x-tar",
|
||||
"application/x-cpio",
|
||||
"application/x-xz",
|
||||
"application/x-7z-compressed",
|
||||
"application/gzip",
|
||||
"application/zstd",
|
||||
"application/x-rar",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.ms-cab-compressed",
|
||||
"application/x-bzip2",
|
||||
"application/x-lzip",
|
||||
"application/x-iso9660-image",
|
||||
"application/vnd.android.package-archive",
|
||||
"application/vnd.debian.binary-package",
|
||||
"application/x-rpm",
|
||||
"application/java-archive",
|
||||
"application/vnd.openxmlformats"
|
||||
], []],
|
||||
MIMECategory.Text : [[
|
||||
"text",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
], []],
|
||||
MIMECategory.AV : [[
|
||||
"audio", "video", "image",
|
||||
"application/mxf"
|
||||
], []],
|
||||
MIMECategory.Document : [[
|
||||
"application/pdf",
|
||||
"application/epub",
|
||||
"application/x-mobipocket-ebook",
|
||||
], []],
|
||||
MIMECategory.Fallback : [[], []]
|
||||
}
|
||||
|
||||
self.exceptions = {
|
||||
MIMECategory.Archive : {
|
||||
".cbz" : MIMECategory.Document,
|
||||
".xps" : MIMECategory.Document,
|
||||
".epub" : MIMECategory.Document,
|
||||
},
|
||||
MIMECategory.Text : {
|
||||
".fb2" : MIMECategory.Document,
|
||||
}
|
||||
}
|
||||
|
||||
def register(self, category, handler):
|
||||
self.handlers[category][1].append(handler)
|
||||
|
||||
def handle(self, mime, ext):
|
||||
def getcat(s):
|
||||
cat = MIMECategory.Fallback
|
||||
for k, v in self.handlers.items():
|
||||
s = s.split(";")[0]
|
||||
if s in v[0] or s.split("/")[0] in v[0]:
|
||||
cat = k
|
||||
break
|
||||
|
||||
for x in v[0]:
|
||||
if s.startswith(x):
|
||||
cat = k
|
||||
break
|
||||
|
||||
if cat in self.exceptions:
|
||||
cat = self.exceptions[cat].get(ext) or cat
|
||||
|
||||
return cat
|
||||
|
||||
cat = getcat(mime)
|
||||
for handler in self.handlers[cat][1]:
|
||||
try:
|
||||
if handler(cat): return
|
||||
except: pass
|
||||
|
||||
for handler in self.handlers[MIMECategory.Fallback][1]:
|
||||
try:
|
||||
if handler(None): return
|
||||
except: pass
|
||||
|
||||
raise RuntimeError(f"Unhandled MIME type category: {cat}")
|
|
@ -1,88 +0,0 @@
|
|||
import time
|
||||
import fcntl, struct, termios
|
||||
from sys import stdout
|
||||
|
||||
from textual import events, log
|
||||
from textual.widgets import Static
|
||||
|
||||
from fhost import app as fhost_app
|
||||
|
||||
class MpvWidget(Static):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.mpv = None
|
||||
self.vo = fhost_app.config.get("MOD_PREVIEW_PROTO")
|
||||
|
||||
if not self.vo in ["sixel", "kitty"]:
|
||||
self.update("⚠ Previews not enabled. \n\nSet MOD_PREVIEW_PROTO to 'sixel' or 'kitty' in config.py,\nwhichever is supported by your terminal.")
|
||||
else:
|
||||
try:
|
||||
import mpv
|
||||
self.mpv = mpv.MPV()
|
||||
self.mpv.profile = "sw-fast"
|
||||
self.mpv["vo"] = self.vo
|
||||
self.mpv[f"vo-{self.vo}-config-clear"] = False
|
||||
self.mpv[f"vo-{self.vo}-alt-screen"] = False
|
||||
self.mpv[f"vo-sixel-buffered"] = True
|
||||
self.mpv["audio"] = False
|
||||
self.mpv["loop-file"] = "inf"
|
||||
self.mpv["image-display-duration"] = 0.5 if self.vo == "sixel" else "inf"
|
||||
except Exception as e:
|
||||
self.mpv = None
|
||||
self.update(f"⚠ Previews require python-mpv with libmpv 0.36.0 or later \n\nError was:\n{type(e).__name__}: {e}")
|
||||
|
||||
def start_mpv(self, f: str|None = None, pos: float|str|None = None) -> None:
|
||||
self.display = True
|
||||
self.screen._refresh_layout()
|
||||
|
||||
if self.mpv:
|
||||
if self.content_region.x:
|
||||
r, c, w, h = struct.unpack('hhhh', fcntl.ioctl(0, termios.TIOCGWINSZ, '12345678'))
|
||||
width = int((w / c) * self.content_region.width)
|
||||
height = int((h / r) * (self.content_region.height + (1 if self.vo == "sixel" else 0)))
|
||||
self.mpv[f"vo-{self.vo}-left"] = self.content_region.x + 1
|
||||
self.mpv[f"vo-{self.vo}-top"] = self.content_region.y + 1
|
||||
self.mpv[f"vo-{self.vo}-rows"] = self.content_region.height + (1 if self.vo == "sixel" else 0)
|
||||
self.mpv[f"vo-{self.vo}-cols"] = self.content_region.width
|
||||
self.mpv[f"vo-{self.vo}-width"] = width
|
||||
self.mpv[f"vo-{self.vo}-height"] = height
|
||||
|
||||
if pos != None:
|
||||
self.mpv["start"] = pos
|
||||
|
||||
if f:
|
||||
self.mpv.loadfile(f)
|
||||
else:
|
||||
self.mpv.playlist_play_index(0)
|
||||
|
||||
def stop_mpv(self, wait: bool = False) -> None:
|
||||
if self.mpv:
|
||||
if not self.mpv.idle_active:
|
||||
self.mpv.stop(True)
|
||||
if wait:
|
||||
time.sleep(0.1)
|
||||
self.clear_mpv()
|
||||
self.display = False
|
||||
|
||||
def on_resize(self, size) -> None:
|
||||
if self.mpv:
|
||||
if not self.mpv.idle_active:
|
||||
t = self.mpv.time_pos
|
||||
self.stop_mpv()
|
||||
if t:
|
||||
self.mpv["start"] = t
|
||||
self.start_mpv()
|
||||
|
||||
def clear_mpv(self) -> None:
|
||||
if self.vo == "kitty":
|
||||
stdout.write("\033_Ga=d;\033\\")
|
||||
stdout.flush()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self.mpv:
|
||||
self.mpv.stop()
|
||||
del self.mpv
|
||||
if self.vo == "kitty":
|
||||
stdout.write("\033_Ga=d;\033\\\033[?25l")
|
||||
stdout.flush()
|
|
@ -1,8 +0,0 @@
|
|||
from textual.widgets import Static
|
||||
|
||||
class Notification(Static):
|
||||
def on_mount(self) -> None:
|
||||
self.set_timer(3, self.remove)
|
||||
|
||||
def on_click(self) -> None:
|
||||
self.remove()
|
35
nginx.conf
35
nginx.conf
|
@ -1,35 +0,0 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ttm.sh;
|
||||
return 307 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name ttm.sh;
|
||||
|
||||
# include your certs
|
||||
ssl_certificate /etc/letsencrypt/live/ttm.sh/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/ttm.sh/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
root /opt/ttm.sh;
|
||||
|
||||
location / {
|
||||
include uwsgi_params;
|
||||
uwsgi_param UWSGI_SCHEME $scheme;
|
||||
|
||||
# make sure this matches the port you're running uwsgi on
|
||||
uwsgi_pass 127.0.0.1:3031;
|
||||
}
|
||||
|
||||
location /up/ {
|
||||
internal;
|
||||
}
|
||||
location /favicon.ico {
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
|
@ -1,56 +1,29 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Copyright © 2020 Mia Herkt
|
||||
Licensed under the EUPL, Version 1.2 or - as soon as approved
|
||||
by the European Commission - subsequent versions of the EUPL
|
||||
(the "License");
|
||||
You may not use this work except in compliance with the License.
|
||||
You may obtain a copy of the license at:
|
||||
|
||||
https://joinup.ec.europa.eu/software/page/eupl
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
|
||||
either express or implied.
|
||||
See the License for the specific language governing permissions
|
||||
and limitations under the License.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import os
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from subprocess import run, PIPE, DEVNULL
|
||||
|
||||
os.environ["GLOG_minloglevel"] = "2" # seriously :|
|
||||
os.environ["GLOG_minloglevel"] = "2" # seriously :|
|
||||
import caffe
|
||||
import av
|
||||
av.logging.set_level(av.logging.PANIC)
|
||||
|
||||
class NSFWDetector:
|
||||
def __init__(self):
|
||||
npath = Path(__file__).parent / "nsfw_model"
|
||||
self.nsfw_net = caffe.Net(
|
||||
str(npath / "deploy.prototxt"),
|
||||
caffe.TEST,
|
||||
weights = str(npath / "resnet_50_1by2_nsfw.caffemodel")
|
||||
)
|
||||
self.caffe_transformer = caffe.io.Transformer({
|
||||
'data': self.nsfw_net.blobs['data'].data.shape
|
||||
})
|
||||
# move image channels to outermost
|
||||
self.caffe_transformer.set_transpose('data', (2, 0, 1))
|
||||
# subtract the dataset-mean value in each channel
|
||||
self.caffe_transformer.set_mean('data', np.array([104, 117, 123]))
|
||||
# rescale from [0, 1] to [0, 255]
|
||||
self.caffe_transformer.set_raw_scale('data', 255)
|
||||
# swap channels from RGB to BGR
|
||||
self.caffe_transformer.set_channel_swap('data', (2, 1, 0))
|
||||
|
||||
npath = os.path.join(os.path.dirname(__file__), "nsfw_model")
|
||||
self.nsfw_net = caffe.Net(os.path.join(npath, "deploy.prototxt"),
|
||||
os.path.join(npath, "resnet_50_1by2_nsfw.caffemodel"),
|
||||
caffe.TEST)
|
||||
self.caffe_transformer = caffe.io.Transformer({'data': self.nsfw_net.blobs['data'].data.shape})
|
||||
self.caffe_transformer.set_transpose('data', (2, 0, 1)) # move image channels to outermost
|
||||
self.caffe_transformer.set_mean('data', np.array([104, 117, 123])) # subtract the dataset-mean value in each channel
|
||||
self.caffe_transformer.set_raw_scale('data', 255) # rescale from [0, 1] to [0, 255]
|
||||
self.caffe_transformer.set_channel_swap('data', (2, 1, 0)) # swap channels from RGB to BGR
|
||||
|
||||
def _compute(self, img):
|
||||
image = caffe.io.load_image(img)
|
||||
image = caffe.io.load_image(BytesIO(img))
|
||||
|
||||
H, W, _ = image.shape
|
||||
_, _, h, w = self.nsfw_net.blobs["data"].data.shape
|
||||
|
@ -63,8 +36,8 @@ class NSFWDetector:
|
|||
|
||||
input_name = self.nsfw_net.inputs[0]
|
||||
output_layers = ["prob"]
|
||||
all_outputs = self.nsfw_net.forward_all(
|
||||
blobs=output_layers, **{input_name: transformed_image})
|
||||
all_outputs = self.nsfw_net.forward_all(blobs=output_layers,
|
||||
**{input_name: transformed_image})
|
||||
|
||||
outputs = all_outputs[output_layers[0]][0].astype(float)
|
||||
|
||||
|
@ -72,28 +45,14 @@ class NSFWDetector:
|
|||
|
||||
def detect(self, fpath):
|
||||
try:
|
||||
with av.open(fpath) as container:
|
||||
try: container.seek(int(container.duration / 2))
|
||||
except: container.seek(0)
|
||||
|
||||
frame = next(container.decode(video=0))
|
||||
|
||||
if frame.width >= frame.height:
|
||||
w = 256
|
||||
h = int(frame.height * (256 / frame.width))
|
||||
else:
|
||||
w = int(frame.width * (256 / frame.height))
|
||||
h = 256
|
||||
frame = frame.reformat(width=w, height=h, format="rgb24")
|
||||
img = BytesIO()
|
||||
frame.to_image().save(img, format="ppm")
|
||||
|
||||
scores = self._compute(img)
|
||||
ff = run(["ffmpegthumbnailer", "-m", "-o-", "-s256", "-t50%", "-a", "-cpng", "-i", fpath], stdout=PIPE, stderr=DEVNULL, check=True)
|
||||
image_data = ff.stdout
|
||||
except:
|
||||
return -1.0
|
||||
|
||||
return scores[1]
|
||||
scores = self._compute(image_data)
|
||||
|
||||
return scores[1]
|
||||
|
||||
if __name__ == "__main__":
|
||||
n = NSFWDetector()
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
[tool.pytest.ini_options]
|
||||
log_level = "INFO"
|
|
@ -1,25 +1,20 @@
|
|||
click
|
||||
Flask_Migrate
|
||||
validators
|
||||
alembic
|
||||
requests
|
||||
Jinja2
|
||||
Flask
|
||||
flask_sqlalchemy
|
||||
python_magic
|
||||
|
||||
# vscan
|
||||
clamd
|
||||
|
||||
# nsfw detection
|
||||
numpy
|
||||
|
||||
# mod ui
|
||||
av
|
||||
PyMuPDF
|
||||
libarchive_c
|
||||
textual
|
||||
python-mpv
|
||||
|
||||
# dev
|
||||
pytest
|
||||
alembic==0.8.8
|
||||
click==6.6
|
||||
decorator==4.0.10
|
||||
Flask==0.11.1
|
||||
Flask-Migrate==2.0.0
|
||||
Flask-Script==2.0.5
|
||||
Flask-SQLAlchemy==2.1
|
||||
humanize==0.5.1
|
||||
itsdangerous==0.24
|
||||
Jinja2==2.8
|
||||
Mako==1.0.4
|
||||
MarkupSafe==0.23
|
||||
python-editor==1.0.1
|
||||
python-magic==0.4.12
|
||||
requests==2.11.1
|
||||
short-url==1.2.2
|
||||
six==1.10.0
|
||||
SQLAlchemy==1.1.3
|
||||
validators==0.11.0
|
||||
Werkzeug==0.11.11
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
rm: cannot remove '{{ request.path.split("/")[1] }}': Permission denied
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{% set pid = range(20,100)|random %}
|
||||
<pre>Process {{ pid }} stopped
|
||||
* thread #1: tid = {{ pid }}, {{ "{:#018x}".format(id(g)) }}, name = 'fhost'
|
||||
frame #0:
|
||||
Process {{ pid }} stopped
|
||||
* thread #8: tid = {{ pid }}, {{ "{:#018x}".format(id(request)) }} fhost`get(path='{{ request.path }}') + 27 at fhost.c:139, name = 'fhost/responder', stop reason = invalid address (fault address: 0x30)
|
||||
frame #0: {3:#018x} fhost`get(path='{{ request.path }}') + 27 at fhost.c:139
|
||||
136 get(SrvContext *ctx, const char *path)
|
||||
137 {
|
||||
138 StoredObj *obj = ctx->store->query(shurl_debase(path));
|
||||
-> 139 switch (obj->type) {
|
||||
140 case ObjTypeFile:
|
||||
141 ctx->serve_file_id(obj->id);
|
||||
142 break;
|
||||
(lldb) q</pre>
|
|
@ -1 +0,0 @@
|
|||
Could not determine remote file size (no Content-Length in response header; shoot admin).
|
|
@ -1 +0,0 @@
|
|||
Remote file too large ({{ request.headers["content-length"]|filesizeformat(True) }} > {{ config["MAX_CONTENT_LENGTH"]|filesizeformat(True) }}).
|
|
@ -1 +0,0 @@
|
|||
451 Unavailable For Legal Reasons
|
|
@ -1,69 +0,0 @@
|
|||
<pre>
|
||||
THE NULL POINTER
|
||||
================
|
||||
{% set fhost_url = url_for("fhost", _external=True).rstrip("/") %}
|
||||
HTTP POST files here:
|
||||
curl -F'file=@yourfile.png' {{ fhost_url }}
|
||||
You can also POST remote URLs:
|
||||
curl -F'url=http://example.com/image.jpg' {{ fhost_url }}
|
||||
If you don't want the resulting URL to be easy to guess:
|
||||
curl -F'file=@yourfile.png' -Fsecret= {{ fhost_url }}
|
||||
curl -F'url=http://example.com/image.jpg' -Fsecret= {{ fhost_url }}
|
||||
Or you can shorten URLs:
|
||||
curl -F'shorten=http://example.com/some/long/url' {{ fhost_url }}
|
||||
|
||||
It is possible to append your own file name to the URL:
|
||||
{{ fhost_url }}/aaa.jpg/image.jpeg
|
||||
|
||||
File URLs are valid for at least 30 days and up to a year (see below).
|
||||
Shortened URLs do not expire.
|
||||
|
||||
Files can be set to expire sooner by adding an "expires" parameter (in hours)
|
||||
curl -F'file=@yourfile.png' -Fexpires=24 {{ fhost_url }}
|
||||
OR by setting "expires" to a timestamp in epoch milliseconds
|
||||
curl -F'file=@yourfile.png' -Fexpires=1681996320000 {{ fhost_url }}
|
||||
|
||||
Expired files won't be removed immediately, but will be removed as part of
|
||||
the next purge.
|
||||
|
||||
Whenever a file that does not already exist or has expired is uploaded,
|
||||
the HTTP response header includes an X-Token field. You can use this
|
||||
to perform management operations on the file.
|
||||
|
||||
To delete the file immediately:
|
||||
curl -Ftoken=token_here -Fdelete= {{ fhost_url }}/abc.txt
|
||||
To change the expiration date (see above):
|
||||
curl -Ftoken=token_here -Fexpires=3 {{ fhost_url }}/abc.txt
|
||||
|
||||
{% set max_size = config["MAX_CONTENT_LENGTH"]|filesizeformat(True) %}
|
||||
Maximum file size: {{ max_size }}
|
||||
Not allowed: {{ config["FHOST_MIME_BLACKLIST"]|join(", ") }}
|
||||
|
||||
|
||||
FILE RETENTION PERIOD
|
||||
---------------------
|
||||
|
||||
retention = min_age + (-max_age + min_age) * pow((file_size / max_size - 1), 3)
|
||||
|
||||
days
|
||||
{{'{: 6}'.format(config.get("FHOST_MAX_EXPIRATION", 31536000000)//86400000)}} | \
|
||||
| \
|
||||
| \
|
||||
| \
|
||||
| \
|
||||
| \
|
||||
| ..
|
||||
| \
|
||||
{{'{: 6.1f}'.format((config.get("FHOST_MIN_EXPIRATION", 2592000000)/2 + config.get("FHOST_MAX_EXPIRATION", 31536000000)/2)/86400000)}} | ----------..-------------------------------------------
|
||||
| ..
|
||||
| \
|
||||
| ..
|
||||
| ...
|
||||
| ..
|
||||
| ...
|
||||
| ....
|
||||
| ......
|
||||
{{'{: 6}'.format(config.get("FHOST_MIN_EXPIRATION", 2592000000)//86400000)}} | ....................
|
||||
0{{ ((config["MAX_CONTENT_LENGTH"]/2)|filesizeformat(True)).split(" ")[0].rjust(27) }}{{ max_size.split(" ")[0].rjust(27) }}
|
||||
{{ max_size.split(" ")[1].rjust(54) }}
|
||||
</pre>
|
|
@ -1,81 +0,0 @@
|
|||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from flask_migrate import upgrade as db_upgrade
|
||||
from io import BytesIO
|
||||
|
||||
from fhost import app, db, url_for, File, URL
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{tmpdir}/db.sqlite"
|
||||
app.config["FHOST_STORAGE_PATH"] = os.path.join(tmpdir, "up")
|
||||
app.config["TESTING"] = True
|
||||
|
||||
with app.test_client() as client:
|
||||
with app.app_context():
|
||||
db_upgrade()
|
||||
yield client
|
||||
|
||||
def test_client(client):
|
||||
payloads = [
|
||||
({ "file" : (BytesIO(b"hello"), "hello.txt") }, 200, b"https://localhost/E.txt\n"),
|
||||
({ "file" : (BytesIO(b"hello"), "hello.ignorethis") }, 200, b"https://localhost/E.txt\n"),
|
||||
({ "file" : (BytesIO(b"bye"), "bye.truncatethis") }, 200, b"https://localhost/Q.truncate\n"),
|
||||
({ "file" : (BytesIO(b"hi"), "hi.tar.gz") }, 200, b"https://localhost/h.tar.gz\n"),
|
||||
({ "file" : (BytesIO(b"lea!"), "lea!") }, 200, b"https://localhost/d.txt\n"),
|
||||
({ "file" : (BytesIO(b"why?"), "balls", "application/x-dosexec") }, 415, None),
|
||||
({ "shorten" : "https://0x0.st" }, 200, b"https://localhost/E\n"),
|
||||
({ "shorten" : "https://localhost" }, 400, None),
|
||||
({}, 400, None),
|
||||
]
|
||||
|
||||
for p, s, r in payloads:
|
||||
rv = client.post("/", buffered=True,
|
||||
content_type="multipart/form-data",
|
||||
data=p)
|
||||
assert rv.status_code == s
|
||||
if r:
|
||||
assert rv.data == r
|
||||
|
||||
f = File.query.get(2)
|
||||
f.removed = True
|
||||
db.session.add(f)
|
||||
db.session.commit()
|
||||
|
||||
rq = [
|
||||
(200, [
|
||||
"/",
|
||||
"robots.txt",
|
||||
"E.txt",
|
||||
"E.txt/test",
|
||||
"E.txt/test.py",
|
||||
"d.txt",
|
||||
"h.tar.gz",
|
||||
]),
|
||||
(302, [
|
||||
"E",
|
||||
]),
|
||||
(404, [
|
||||
"test.bin",
|
||||
"test.bin/test",
|
||||
"test.bin/test.py",
|
||||
"test",
|
||||
"test/test",
|
||||
"test.bin/test.py",
|
||||
"E.bin",
|
||||
"E/test",
|
||||
"E/test.bin",
|
||||
]),
|
||||
(451, [
|
||||
"Q.truncate",
|
||||
]),
|
||||
]
|
||||
|
||||
for code, paths in rq:
|
||||
for p in paths:
|
||||
app.logger.info(f"GET {p}")
|
||||
rv = client.get(p)
|
||||
assert rv.status_code == code
|
||||
|
17
ttm.service
17
ttm.service
|
@ -1,17 +0,0 @@
|
|||
# /etc/systemd/system/ttm.service
|
||||
[Unit]
|
||||
Description=null pointer
|
||||
After=ttm.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/srv/ttm/ttm.sh
|
||||
ExecStart=/usr/local/bin/uwsgi --socket 127.0.0.1:3031 --wsgi-file fhost.py --callable app --processes 4 --threads 2
|
||||
User=ttm
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StartLimitInterval=60s
|
||||
StartLimitBurst=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
Loading…
Reference in New Issue