Compare commits
1 Commits
Author | SHA1 | Date |
---|---|---|
rubenwardy | 04db19d1ac |
|
@ -3,4 +3,3 @@ data*
|
||||||
uploads
|
uploads
|
||||||
*.pyc
|
*.pyc
|
||||||
__pycache__
|
__pycache__
|
||||||
env
|
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
patreon: rubenwardy
|
|
||||||
custom: [ "https://rubenwardy.com/donate/" ]
|
|
|
@ -1,21 +0,0 @@
|
||||||
name: Tests
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Copy config
|
|
||||||
run: cp utils/ci/* .
|
|
||||||
- name: Build the Docker image
|
|
||||||
run: docker-compose build
|
|
||||||
- name: Start Docker
|
|
||||||
run: docker-compose up -d
|
|
||||||
- name: Run migrations
|
|
||||||
run: ./utils/run_migrations.sh
|
|
||||||
- name: Run tests
|
|
||||||
run: ./utils/tests_cov.sh
|
|
||||||
- name: Stop Docker
|
|
||||||
run: docker-compose down
|
|
|
@ -10,8 +10,6 @@ app/public/uploads
|
||||||
app/public/thumbnails
|
app/public/thumbnails
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
/data
|
/data
|
||||||
.idea
|
|
||||||
*.mo
|
|
||||||
|
|
||||||
# Created by https://www.gitignore.io/api/linux,macos,python,windows
|
# Created by https://www.gitignore.io/api/linux,macos,python,windows
|
||||||
|
|
||||||
|
@ -106,6 +104,10 @@ coverage.xml
|
||||||
*.cover
|
*.cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
.webassets-cache
|
.webassets-cache
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
image: docker/compose
|
||||||
|
services:
|
||||||
|
- docker:dind
|
||||||
|
cache:
|
||||||
|
key: "$CI_COMMIT_REF_SLUG"
|
||||||
|
paths:
|
||||||
|
- /var/lib/docker
|
||||||
|
|
||||||
|
# build:
|
||||||
|
# stage: build
|
||||||
|
# script:
|
||||||
|
# - cp utils/gitlabci/* .
|
||||||
|
# - docker-compose build
|
||||||
|
|
||||||
|
UI_Test:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- cp utils/gitlabci/* .
|
||||||
|
- docker-compose up -d
|
||||||
|
- ./utils/run_migrations.sh
|
||||||
|
- ./utils/tests_cov.sh
|
||||||
|
- docker-compose down
|
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.10
|
FROM python:3.6
|
||||||
|
|
||||||
RUN groupadd -g 5123 cdb && \
|
RUN groupadd -g 5123 cdb && \
|
||||||
useradd -r -u 5123 -g cdb cdb
|
useradd -r -u 5123 -g cdb cdb
|
||||||
|
@ -8,17 +8,15 @@ WORKDIR /home/cdb
|
||||||
RUN mkdir /var/cdb
|
RUN mkdir /var/cdb
|
||||||
RUN chown -R cdb:cdb /var/cdb
|
RUN chown -R cdb:cdb /var/cdb
|
||||||
|
|
||||||
COPY requirements.lock.txt requirements.lock.txt
|
COPY requirements.txt requirements.txt
|
||||||
RUN pip install -r requirements.lock.txt
|
RUN pip install -r requirements.txt
|
||||||
RUN pip install gunicorn
|
RUN pip install gunicorn
|
||||||
|
|
||||||
COPY utils utils
|
COPY utils utils
|
||||||
COPY config.cfg config.cfg
|
COPY config.cfg config.cfg
|
||||||
COPY migrations migrations
|
COPY migrations migrations
|
||||||
COPY app app
|
COPY app app
|
||||||
COPY translations translations
|
|
||||||
|
|
||||||
RUN pybabel compile -d translations
|
|
||||||
RUN chown -R cdb:cdb /home/cdb
|
RUN chown -R cdb:cdb /home/cdb
|
||||||
|
|
||||||
USER cdb
|
USER cdb
|
||||||
|
|
660
LICENSE.md
|
@ -1,660 +0,0 @@
|
||||||
### GNU AFFERO GENERAL PUBLIC LICENSE
|
|
||||||
|
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
|
||||||
<https://fsf.org/>
|
|
||||||
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies of this
|
|
||||||
license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
### Preamble
|
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains
|
|
||||||
free software for all its users.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing
|
|
||||||
under this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
### TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
#### 0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public
|
|
||||||
License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds
|
|
||||||
of works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of
|
|
||||||
an exact copy. The resulting work is called a "modified version" of
|
|
||||||
the earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user
|
|
||||||
through a computer network, with no transfer of a copy, is not
|
|
||||||
conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices" to
|
|
||||||
the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
#### 1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work for
|
|
||||||
making modifications to it. "Object code" means any non-source form of
|
|
||||||
a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users can
|
|
||||||
regenerate automatically from other parts of the Corresponding Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that same
|
|
||||||
work.
|
|
||||||
|
|
||||||
#### 2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not convey,
|
|
||||||
without conditions so long as your license otherwise remains in force.
|
|
||||||
You may convey covered works to others for the sole purpose of having
|
|
||||||
them make modifications exclusively for you, or provide you with
|
|
||||||
facilities for running those works, provided that you comply with the
|
|
||||||
terms of this License in conveying all material for which you do not
|
|
||||||
control copyright. Those thus making or running the covered works for
|
|
||||||
you must do so exclusively on your behalf, under your direction and
|
|
||||||
control, on terms that prohibit them from making any copies of your
|
|
||||||
copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under the
|
|
||||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
|
||||||
it unnecessary.
|
|
||||||
|
|
||||||
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such
|
|
||||||
circumvention is effected by exercising rights under this License with
|
|
||||||
respect to the covered work, and you disclaim any intention to limit
|
|
||||||
operation or modification of the work as a means of enforcing, against
|
|
||||||
the work's users, your or third parties' legal rights to forbid
|
|
||||||
circumvention of technological measures.
|
|
||||||
|
|
||||||
#### 4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
#### 5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these
|
|
||||||
conditions:
|
|
||||||
|
|
||||||
- a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
- b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under
|
|
||||||
section 7. This requirement modifies the requirement in section 4
|
|
||||||
to "keep intact all notices".
|
|
||||||
- c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
- d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
#### 6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms of
|
|
||||||
sections 4 and 5, provided that you also convey the machine-readable
|
|
||||||
Corresponding Source under the terms of this License, in one of these
|
|
||||||
ways:
|
|
||||||
|
|
||||||
- a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
- b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the Corresponding
|
|
||||||
Source from a network server at no charge.
|
|
||||||
- c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
- d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
- e) Convey the object code using peer-to-peer transmission,
|
|
||||||
provided you inform other peers where the object code and
|
|
||||||
Corresponding Source of the work are being offered to the general
|
|
||||||
public at no charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal,
|
|
||||||
family, or household purposes, or (2) anything designed or sold for
|
|
||||||
incorporation into a dwelling. In determining whether a product is a
|
|
||||||
consumer product, doubtful cases shall be resolved in favor of
|
|
||||||
coverage. For a particular product received by a particular user,
|
|
||||||
"normally used" refers to a typical or common use of that class of
|
|
||||||
product, regardless of the status of the particular user or of the way
|
|
||||||
in which the particular user actually uses, or expects or is expected
|
|
||||||
to use, the product. A product is a consumer product regardless of
|
|
||||||
whether the product has substantial commercial, industrial or
|
|
||||||
non-consumer uses, unless such uses represent the only significant
|
|
||||||
mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to
|
|
||||||
install and execute modified versions of a covered work in that User
|
|
||||||
Product from a modified version of its Corresponding Source. The
|
|
||||||
information must suffice to ensure that the continued functioning of
|
|
||||||
the modified object code is in no case prevented or interfered with
|
|
||||||
solely because modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or
|
|
||||||
updates for a work that has been modified or installed by the
|
|
||||||
recipient, or for the User Product in which it has been modified or
|
|
||||||
installed. Access to a network may be denied when the modification
|
|
||||||
itself materially and adversely affects the operation of the network
|
|
||||||
or violates the rules and protocols for communication across the
|
|
||||||
network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
#### 7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders
|
|
||||||
of that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
- a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
- b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
- c) Prohibiting misrepresentation of the origin of that material,
|
|
||||||
or requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
- d) Limiting the use for publicity purposes of names of licensors
|
|
||||||
or authors of the material; or
|
|
||||||
- e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
- f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions
|
|
||||||
of it) with contractual assumptions of liability to the recipient,
|
|
||||||
for any liability that these contractual assumptions directly
|
|
||||||
impose on those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions; the
|
|
||||||
above requirements apply either way.
|
|
||||||
|
|
||||||
#### 8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your license
|
|
||||||
from a particular copyright holder is reinstated (a) provisionally,
|
|
||||||
unless and until the copyright holder explicitly and finally
|
|
||||||
terminates your license, and (b) permanently, if the copyright holder
|
|
||||||
fails to notify you of the violation by some reasonable means prior to
|
|
||||||
60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
#### 9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or run
|
|
||||||
a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
#### 10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
#### 11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims owned
|
|
||||||
or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within the
|
|
||||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
|
||||||
the non-exercise of one or more of the rights that are specifically
|
|
||||||
granted under this License. You may not convey a covered work if you
|
|
||||||
are a party to an arrangement with a third party that is in the
|
|
||||||
business of distributing software, under which you make payment to the
|
|
||||||
third party based on the extent of your activity of conveying the
|
|
||||||
work, and under which the third party grants, to any of the parties
|
|
||||||
who would receive the covered work from you, a discriminatory patent
|
|
||||||
license (a) in connection with copies of the covered work conveyed by
|
|
||||||
you (or copies made from those copies), or (b) primarily for and in
|
|
||||||
connection with specific products or compilations that contain the
|
|
||||||
covered work, unless you entered into that arrangement, or that patent
|
|
||||||
license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
#### 12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under
|
|
||||||
this License and any other pertinent obligations, then as a
|
|
||||||
consequence you may not convey it at all. For example, if you agree to
|
|
||||||
terms that obligate you to collect a royalty for further conveying
|
|
||||||
from those to whom you convey the Program, the only way you could
|
|
||||||
satisfy both those terms and this License would be to refrain entirely
|
|
||||||
from conveying the Program.
|
|
||||||
|
|
||||||
#### 13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your
|
|
||||||
version supports such interaction) an opportunity to receive the
|
|
||||||
Corresponding Source of your version by providing access to the
|
|
||||||
Corresponding Source from a network server at no charge, through some
|
|
||||||
standard or customary means of facilitating copying of software. This
|
|
||||||
Corresponding Source shall include the Corresponding Source for any
|
|
||||||
work covered by version 3 of the GNU General Public License that is
|
|
||||||
incorporated pursuant to the following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
#### 14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions
|
|
||||||
of the GNU Affero General Public License from time to time. Such new
|
|
||||||
versions will be similar in spirit to the present version, but may
|
|
||||||
differ in detail to address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the Program
|
|
||||||
specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever
|
|
||||||
published by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future versions
|
|
||||||
of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
#### 15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
|
||||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
|
||||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
||||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
|
||||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
|
||||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
|
||||||
CORRECTION.
|
|
||||||
|
|
||||||
#### 16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
|
||||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
|
||||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
|
||||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
|
||||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
|
||||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
|
||||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
|
||||||
|
|
||||||
#### 17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
### How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these
|
|
||||||
terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest to
|
|
||||||
attach them to the start of each source file to most effectively state
|
|
||||||
the exclusion of warranty; and each file should have at least the
|
|
||||||
"copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper
|
|
||||||
mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for
|
|
||||||
the specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or
|
|
||||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
|
||||||
necessary. For more information on this, and how to apply and follow
|
|
||||||
the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
|
|
@ -0,0 +1,674 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
71
README.md
|
@ -1,67 +1,36 @@
|
||||||
# Content Database
|
# Content Database
|
||||||
![Build Status](https://github.com/minetest/contentdb/actions/workflows/test.yml/badge.svg)
|
[![Build status](https://gitlab.com/minetest/contentdb/badges/master/pipeline.svg)](https://gitlab.com/minetest/contentdb/pipelines)
|
||||||
|
|
||||||
Content database for Minetest mods, games, and more.\
|
Content database for Minetest mods, games, and more.\
|
||||||
Developed by rubenwardy, license AGPLv3.0+.
|
Developed by rubenwardy, license GPLv3.0+.
|
||||||
|
|
||||||
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
|
|
||||||
|
|
||||||
See [Developer Intro](docs/dev_intro.md) for an overview of the code organisation.
|
|
||||||
|
|
||||||
## How-tos
|
## How-tos
|
||||||
|
|
||||||
|
Note: you should first read one of the guides on the [Github repo wiki](https://github.com/minetest/contentdb/wiki)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Run celery worker
|
||||||
|
FLASK_CONFIG=../config.cfg celery -A app.tasks.celery worker
|
||||||
|
|
||||||
|
# if sqlite
|
||||||
|
python utils/setup.py -t
|
||||||
|
rm db.sqlite && python setup.py -t && FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db stamp head
|
||||||
|
|
||||||
|
# Create migration
|
||||||
|
FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db migrate
|
||||||
|
# Run migration
|
||||||
|
FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db upgrade
|
||||||
|
|
||||||
|
# Enter docker
|
||||||
|
docker exec -it contentdb_app_1 bash
|
||||||
|
|
||||||
# Hot/live reload (only works with FLASK_DEBUG=1)
|
# Hot/live reload (only works with FLASK_DEBUG=1)
|
||||||
./utils/reload.sh
|
./utils/reload.sh
|
||||||
|
|
||||||
# Cold update a running version of CDB with minimal downtime (production)
|
# Cold update a running version of CDB with minimal downtime
|
||||||
./utils/update.sh
|
./utils/update.sh
|
||||||
|
|
||||||
# Enter docker
|
|
||||||
./utils/bash.sh
|
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
./utils/run_migrations.sh
|
|
||||||
|
|
||||||
# Create new migration
|
|
||||||
./utils/create_migration.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### VSCode: Setting up Linting
|
|
||||||
|
|
||||||
* (optional) Install the [Docker extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker)
|
|
||||||
* Install the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python)
|
|
||||||
* Click no to installing pylint (we don't want it to be installed outside of a virtual env)
|
|
||||||
* Set up a virtual env
|
|
||||||
* Replace `psycopg2` with `psycopg2_binary` in requirements.txt (because postgresql won't be installed on the system)
|
|
||||||
* `python3 -m venv env`
|
|
||||||
* Click yes to prompt to select virtual env for workspace
|
|
||||||
* Click yes to any prompts about installing pylint
|
|
||||||
* `source env/bin/activate`
|
|
||||||
* `pip install -r requirements`
|
|
||||||
* `pip install pylint` (if a prompt didn't appear)
|
|
||||||
* Undo changes to requirements.txt
|
|
||||||
|
|
||||||
### VSCode: Material Icon Folder Designations
|
|
||||||
|
|
||||||
```json
|
|
||||||
"material-icon-theme.folders.associations": {
|
|
||||||
"packages": "",
|
|
||||||
"tasks": "",
|
|
||||||
"api": "",
|
|
||||||
"meta": "",
|
|
||||||
"blueprints": "routes",
|
|
||||||
"scss": "sass",
|
|
||||||
"flatpages": "markdown",
|
|
||||||
"data": "temp",
|
|
||||||
"migrations": "archive",
|
|
||||||
"textures": "images",
|
|
||||||
"sounds": "audio"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
|
|
||||||
|
|
135
app/__init__.py
|
@ -1,172 +1,101 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import datetime
|
|
||||||
|
|
||||||
from flask import *
|
from flask import *
|
||||||
|
from flask_user import *
|
||||||
from flask_gravatar import Gravatar
|
from flask_gravatar import Gravatar
|
||||||
|
import flask_menu as menu
|
||||||
from flask_mail import Mail
|
from flask_mail import Mail
|
||||||
from flask_github import GitHub
|
from flask_github import GitHub
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
from flask_flatpages import FlatPages
|
from flask_flatpages import FlatPages
|
||||||
from flask_babel import Babel, gettext
|
from flask_babel import Babel
|
||||||
from flask_login import logout_user, current_user, LoginManager
|
|
||||||
import os, redis
|
import os, redis
|
||||||
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
|
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__, static_folder="public/static")
|
app = Flask(__name__, static_folder="public/static")
|
||||||
app.config["FLATPAGES_ROOT"] = "flatpages"
|
app.config["FLATPAGES_ROOT"] = "flatpages"
|
||||||
app.config["FLATPAGES_EXTENSION"] = ".md"
|
app.config["FLATPAGES_EXTENSION"] = ".md"
|
||||||
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = MARKDOWN_EXTENSIONS
|
|
||||||
app.config["FLATPAGES_EXTENSION_CONFIG"] = MARKDOWN_EXTENSION_CONFIG
|
|
||||||
app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations"
|
|
||||||
app.config["LANGUAGES"] = {
|
|
||||||
"en": "English",
|
|
||||||
"de": "Deutsch",
|
|
||||||
"fr": "Français",
|
|
||||||
"id": "Bahasa Indonesia",
|
|
||||||
"ms": "Bahasa Melayu",
|
|
||||||
"ru": "русский язык",
|
|
||||||
}
|
|
||||||
|
|
||||||
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
|
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
|
||||||
|
|
||||||
r = redis.Redis.from_url(app.config["REDIS_URL"])
|
r = redis.Redis.from_url(app.config["REDIS_URL"])
|
||||||
|
|
||||||
|
menu.Menu(app=app)
|
||||||
github = GitHub(app)
|
github = GitHub(app)
|
||||||
csrf = CSRFProtect(app)
|
csrf = CSRFProtect(app)
|
||||||
mail = Mail(app)
|
mail = Mail(app)
|
||||||
pages = FlatPages(app)
|
pages = FlatPages(app)
|
||||||
babel = Babel(app)
|
babel = Babel(app)
|
||||||
gravatar = Gravatar(app,
|
gravatar = Gravatar(app,
|
||||||
size=64,
|
size=58,
|
||||||
rating="g",
|
rating='g',
|
||||||
default="retro",
|
default='mp',
|
||||||
force_default=False,
|
force_default=False,
|
||||||
force_lower=False,
|
force_lower=False,
|
||||||
use_ssl=True,
|
use_ssl=True,
|
||||||
base_url=None)
|
base_url=None)
|
||||||
init_markdown(app)
|
|
||||||
|
|
||||||
login_manager = LoginManager()
|
from .sass import sass
|
||||||
login_manager.init_app(app)
|
|
||||||
login_manager.login_view = "users.login"
|
|
||||||
|
|
||||||
|
|
||||||
from .sass import init_app as sass
|
|
||||||
sass(app)
|
sass(app)
|
||||||
|
|
||||||
|
|
||||||
if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
|
if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
|
||||||
from .maillogger import build_handler
|
from .maillogger import register_mail_error_handler
|
||||||
app.logger.addHandler(build_handler(app))
|
register_mail_error_handler(app, mail)
|
||||||
|
|
||||||
|
|
||||||
from . import models, template_filters
|
from .markdown import init_app
|
||||||
|
init_app(app)
|
||||||
|
|
||||||
|
# @babel.localeselector
|
||||||
|
# def get_locale():
|
||||||
|
# return request.accept_languages.best_match(app.config['LANGUAGES'].keys())
|
||||||
|
|
||||||
@login_manager.user_loader
|
from . import models, tasks, template_filters
|
||||||
def load_user(user_id):
|
|
||||||
return models.User.query.filter_by(username=user_id).first()
|
|
||||||
|
|
||||||
|
|
||||||
from .blueprints import create_blueprints
|
from .blueprints import create_blueprints
|
||||||
create_blueprints(app)
|
create_blueprints(app)
|
||||||
|
|
||||||
|
from flask_login import logout_user
|
||||||
|
|
||||||
@app.route("/uploads/<path:path>")
|
@app.route("/uploads/<path:path>")
|
||||||
def send_upload(path):
|
def send_upload(path):
|
||||||
return send_from_directory(app.config["UPLOAD_DIR"], path)
|
return send_from_directory(app.config['UPLOAD_DIR'], path)
|
||||||
|
|
||||||
@app.route("/<path:path>/")
|
@menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { 'path': 'help' })
|
||||||
|
@app.route('/<path:path>/')
|
||||||
def flatpage(path):
|
def flatpage(path):
|
||||||
page = pages.get_or_404(path)
|
page = pages.get_or_404(path)
|
||||||
template = page.meta.get("template", "flatpage.html")
|
template = page.meta.get('template', 'flatpage.html')
|
||||||
return render_template(template, page=page)
|
return render_template(template, page=page)
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def check_for_ban():
|
def check_for_ban():
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
if current_user.rank == models.UserRank.BANNED:
|
if current_user.rank == models.UserRank.BANNED:
|
||||||
flash(gettext("You have been banned."), "danger")
|
flash("You have been banned.", "danger")
|
||||||
logout_user()
|
logout_user()
|
||||||
return redirect(url_for("users.login"))
|
return redirect(url_for('user.login'))
|
||||||
elif current_user.rank == models.UserRank.NOT_JOINED:
|
elif current_user.rank == models.UserRank.NOT_JOINED:
|
||||||
current_user.rank = models.UserRank.MEMBER
|
current_user.rank = models.UserRank.MEMBER
|
||||||
models.db.session.commit()
|
models.db.session.commit()
|
||||||
|
|
||||||
from .utils import clearNotifications, is_safe_url
|
from .utils import clearNotifications
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def check_for_notifications():
|
def check_for_notifications():
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
clearNotifications(request.path)
|
clearNotifications(request.path)
|
||||||
|
|
||||||
@app.errorhandler(404)
|
|
||||||
def page_not_found(e):
|
|
||||||
return render_template("404.html"), 404
|
|
||||||
|
|
||||||
|
|
||||||
@babel.localeselector
|
|
||||||
def get_locale():
|
|
||||||
if not request:
|
|
||||||
return None
|
|
||||||
|
|
||||||
locales = app.config["LANGUAGES"].keys()
|
|
||||||
|
|
||||||
if current_user.is_authenticated and current_user.locale in locales:
|
|
||||||
return current_user.locale
|
|
||||||
|
|
||||||
locale = request.cookies.get("locale")
|
|
||||||
if locale not in locales:
|
|
||||||
locale = request.accept_languages.best_match(locales)
|
|
||||||
|
|
||||||
if locale and current_user.is_authenticated:
|
|
||||||
new_session = models.db.create_session({})()
|
|
||||||
new_session.query(models.User) \
|
|
||||||
.filter(models.User.username == current_user.username) \
|
|
||||||
.update({ "locale": locale })
|
|
||||||
new_session.commit()
|
|
||||||
new_session.close()
|
|
||||||
|
|
||||||
return locale
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/set-locale/", methods=["POST"])
|
|
||||||
@csrf.exempt
|
|
||||||
def set_locale():
|
|
||||||
locale = request.form.get("locale")
|
|
||||||
if locale not in app.config["LANGUAGES"].keys():
|
|
||||||
flash("Unknown locale {}".format(locale), "danger")
|
|
||||||
locale = None
|
|
||||||
|
|
||||||
next_url = request.form.get("r")
|
|
||||||
if next_url and is_safe_url(next_url):
|
|
||||||
resp = make_response(redirect(next_url))
|
|
||||||
else:
|
|
||||||
resp = make_response(redirect(url_for("homepage.home")))
|
|
||||||
|
|
||||||
if locale:
|
|
||||||
expire_date = datetime.datetime.now()
|
|
||||||
expire_date = expire_date + datetime.timedelta(days=5*365)
|
|
||||||
resp.set_cookie("locale", locale, expires=expire_date)
|
|
||||||
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
current_user.locale = locale
|
|
||||||
models.db.session.commit()
|
|
||||||
|
|
||||||
return resp
|
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,4 +19,4 @@ from flask import Blueprint
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__)
|
bp = Blueprint("admin", __name__)
|
||||||
|
|
||||||
from . import admin, audit, licenseseditor, tagseditor, versioneditor, warningseditor, email
|
from . import admin, licenseseditor, tagseditor, versioneditor, audit
|
||||||
|
|
|
@ -1,338 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from celery import group
|
|
||||||
from flask import redirect, url_for, flash, current_app, jsonify
|
|
||||||
from sqlalchemy import or_, and_
|
|
||||||
|
|
||||||
from app.logic.game_support import GameSupportResolver
|
|
||||||
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
|
|
||||||
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, PackageGameSupport
|
|
||||||
from app.tasks.emails import send_pending_digests
|
|
||||||
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
|
|
||||||
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
|
|
||||||
from app.utils import addNotification, get_system_user
|
|
||||||
from app.utils.image import get_image_size
|
|
||||||
|
|
||||||
actions = {}
|
|
||||||
|
|
||||||
|
|
||||||
def action(title: str):
|
|
||||||
def func(f):
|
|
||||||
name = f.__name__
|
|
||||||
actions[name] = {
|
|
||||||
"title": title,
|
|
||||||
"func": f,
|
|
||||||
}
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
return func
|
|
||||||
|
|
||||||
|
|
||||||
@action("Delete stuck releases")
|
|
||||||
def del_stuck_releases():
|
|
||||||
PackageRelease.query.filter(PackageRelease.task_id.isnot(None)).delete()
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for("admin.admin_page"))
|
|
||||||
|
|
||||||
|
|
||||||
@action("Check ZIP releases")
|
|
||||||
def check_releases():
|
|
||||||
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
|
|
||||||
|
|
||||||
tasks = []
|
|
||||||
for release in releases:
|
|
||||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
|
||||||
|
|
||||||
result = group(tasks).apply_async()
|
|
||||||
|
|
||||||
while not result.ready():
|
|
||||||
import time
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
return redirect(url_for("todo.view_editor"))
|
|
||||||
|
|
||||||
|
|
||||||
@action("Check the first release of all packages")
|
|
||||||
def reimport_packages():
|
|
||||||
tasks = []
|
|
||||||
for package in Package.query.filter(Package.state != PackageState.DELETED).all():
|
|
||||||
release = package.releases.first()
|
|
||||||
if release:
|
|
||||||
tasks.append(checkZipRelease.s(release.id, release.file_path))
|
|
||||||
|
|
||||||
result = group(tasks).apply_async()
|
|
||||||
|
|
||||||
while not result.ready():
|
|
||||||
import time
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
return redirect(url_for("todo.view_editor"))
|
|
||||||
|
|
||||||
|
|
||||||
@action("Import forum topic list")
|
|
||||||
def import_topic_list():
|
|
||||||
task = importTopicList.delay()
|
|
||||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
|
|
||||||
|
|
||||||
|
|
||||||
@action("Check all forum accounts")
|
|
||||||
def check_all_forum_accounts():
|
|
||||||
task = checkAllForumAccounts.delay()
|
|
||||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
|
|
||||||
|
|
||||||
|
|
||||||
@action("Import screenshots")
|
|
||||||
def import_screenshots():
|
|
||||||
packages = Package.query \
|
|
||||||
.filter(Package.state != PackageState.DELETED) \
|
|
||||||
.outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \
|
|
||||||
.filter(PackageScreenshot.id.is_(None)) \
|
|
||||||
.all()
|
|
||||||
for package in packages:
|
|
||||||
importRepoScreenshot.delay(package.id)
|
|
||||||
|
|
||||||
return redirect(url_for("admin.admin_page"))
|
|
||||||
|
|
||||||
|
|
||||||
@action("Remove unused uploads")
|
|
||||||
def clean_uploads():
|
|
||||||
upload_dir = current_app.config['UPLOAD_DIR']
|
|
||||||
|
|
||||||
(_, _, filenames) = next(os.walk(upload_dir))
|
|
||||||
existing_uploads = set(filenames)
|
|
||||||
|
|
||||||
if len(existing_uploads) != 0:
|
|
||||||
def get_filenames_from_column(column):
|
|
||||||
results = db.session.query(column).filter(column.isnot(None), column != "").all()
|
|
||||||
return set([os.path.basename(x[0]) for x in results])
|
|
||||||
|
|
||||||
release_urls = get_filenames_from_column(PackageRelease.url)
|
|
||||||
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
|
|
||||||
|
|
||||||
db_urls = release_urls.union(screenshot_urls)
|
|
||||||
unreachable = existing_uploads.difference(db_urls)
|
|
||||||
|
|
||||||
import sys
|
|
||||||
print("On Disk: ", existing_uploads, file=sys.stderr)
|
|
||||||
print("In DB: ", db_urls, file=sys.stderr)
|
|
||||||
print("Unreachable: ", unreachable, file=sys.stderr)
|
|
||||||
|
|
||||||
for filename in unreachable:
|
|
||||||
os.remove(os.path.join(upload_dir, filename))
|
|
||||||
|
|
||||||
flash("Deleted " + str(len(unreachable)) + " unreachable uploads", "success")
|
|
||||||
else:
|
|
||||||
flash("No downloads to create", "danger")
|
|
||||||
|
|
||||||
return redirect(url_for("admin.admin_page"))
|
|
||||||
|
|
||||||
|
|
||||||
@action("Delete unused metapackages")
|
|
||||||
def del_meta_packages():
|
|
||||||
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
|
|
||||||
count = query.count()
|
|
||||||
query.delete(synchronize_session=False)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash("Deleted " + str(count) + " unused meta packages", "success")
|
|
||||||
return redirect(url_for("admin.admin_page"))
|
|
||||||
|
|
||||||
|
|
||||||
@action("Delete removed packages")
|
|
||||||
def del_removed_packages():
|
|
||||||
query = Package.query.filter_by(state=PackageState.DELETED)
|
|
||||||
count = query.count()
|
|
||||||
for pkg in query.all():
|
|
||||||
pkg.review_thread = None
|
|
||||||
db.session.delete(pkg)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash("Deleted {} soft deleted packages packages".format(count), "success")
|
|
||||||
return redirect(url_for("admin.admin_page"))
|
|
||||||
|
|
||||||
|
|
||||||
@action("Run update configs")
|
|
||||||
def run_update_config():
|
|
||||||
check_for_updates.delay()
|
|
||||||
|
|
||||||
flash("Started update configs", "success")
|
|
||||||
return redirect(url_for("admin.admin_page"))
|
|
||||||
|
|
||||||
|
|
||||||
def _package_list(packages: List[str]):
|
|
||||||
# Who needs translations?
|
|
||||||
if len(packages) >= 3:
|
|
||||||
packages[len(packages) - 1] = "and " + packages[len(packages) - 1]
|
|
||||||
packages_list = ", ".join(packages)
|
|
||||||
else:
|
|
||||||
packages_list = " and ".join(packages)
|
|
||||||
return packages_list
|
|
||||||
|
|
||||||
|
|
||||||
@action("Send WIP package notification")
|
|
||||||
def remind_wip():
|
|
||||||
users = User.query.filter(User.packages.any(or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)))
|
|
||||||
system_user = get_system_user()
|
|
||||||
for user in users:
|
|
||||||
packages = db.session.query(Package.title).filter(
|
|
||||||
Package.author_id == user.id,
|
|
||||||
or_(Package.state == PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
packages = [pkg[0] for pkg in packages]
|
|
||||||
packages_list = _package_list(packages)
|
|
||||||
havent = "haven't" if len(packages) > 1 else "hasn't"
|
|
||||||
if len(packages_list) + 54 > 100:
|
|
||||||
packages_list = packages_list[0:(100-54-1)] + "…"
|
|
||||||
|
|
||||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
|
||||||
f"Did you forget? {packages_list} {havent} been submitted for review yet",
|
|
||||||
url_for('todo.view_user', username=user.username))
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@action("Send outdated package notification")
|
|
||||||
def remind_outdated():
|
|
||||||
users = User.query.filter(User.maintained_packages.any(
|
|
||||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))))
|
|
||||||
system_user = get_system_user()
|
|
||||||
for user in users:
|
|
||||||
packages = db.session.query(Package.title).filter(
|
|
||||||
Package.maintainers.any(User.id==user.id),
|
|
||||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
packages = [pkg[0] for pkg in packages]
|
|
||||||
packages_list = _package_list(packages)
|
|
||||||
|
|
||||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
|
||||||
f"The following packages may be outdated: {packages_list}",
|
|
||||||
url_for('todo.view_user', username=user.username))
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@action("Import licenses from SPDX")
|
|
||||||
def import_licenses():
|
|
||||||
renames = {
|
|
||||||
"GPLv2": "GPL-2.0-only",
|
|
||||||
"GPLv3": "GPL-3.0-only",
|
|
||||||
"AGPLv2": "AGPL-2.0-only",
|
|
||||||
"AGPLv3": "AGPL-3.0-only",
|
|
||||||
"LGPLv2.1": "LGPL-2.1-only",
|
|
||||||
"LGPLv3": "LGPL-3.0-only",
|
|
||||||
"Apache 2.0": "Apache-2.0",
|
|
||||||
"BSD 2-Clause / FreeBSD": "BSD-2-Clause-FreeBSD",
|
|
||||||
"BSD 3-Clause": "BSD-3-Clause",
|
|
||||||
"CC0": "CC0-1.0",
|
|
||||||
"CC BY 3.0": "CC-BY-3.0",
|
|
||||||
"CC BY 4.0": "CC-BY-4.0",
|
|
||||||
"CC BY-NC-SA 3.0": "CC-BY-NC-SA-3.0",
|
|
||||||
"CC BY-SA 3.0": "CC-BY-SA-3.0",
|
|
||||||
"CC BY-SA 4.0": "CC-BY-SA-4.0",
|
|
||||||
"NPOSLv3": "NPOSL-3.0",
|
|
||||||
"MPL 2.0": "MPL-2.0",
|
|
||||||
"EUPLv1.2": "EUPL-1.2",
|
|
||||||
"SIL Open Font License v1.1": "OFL-1.1",
|
|
||||||
}
|
|
||||||
|
|
||||||
for old_name, new_name in renames.items():
|
|
||||||
License.query.filter_by(name=old_name).update({ "name": new_name })
|
|
||||||
|
|
||||||
r = requests.get(
|
|
||||||
"https://raw.githubusercontent.com/spdx/license-list-data/master/json/licenses.json")
|
|
||||||
licenses = r.json()["licenses"]
|
|
||||||
|
|
||||||
existing_licenses = {}
|
|
||||||
for license in License.query.all():
|
|
||||||
assert license.name not in renames.keys()
|
|
||||||
existing_licenses[license.name.lower()] = license
|
|
||||||
|
|
||||||
for license in licenses:
|
|
||||||
obj = existing_licenses.get(license["licenseId"].lower())
|
|
||||||
if obj:
|
|
||||||
obj.url = license["reference"]
|
|
||||||
elif license.get("isOsiApproved") and license.get("isFsfLibre") and \
|
|
||||||
not license["isDeprecatedLicenseId"]:
|
|
||||||
obj = License(license["licenseId"], True, license["reference"])
|
|
||||||
db.session.add(obj)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@action("Delete inactive users")
|
|
||||||
def delete_inactive_users():
|
|
||||||
users = User.query.filter(User.is_active == False, User.packages.is_(None), User.forum_topics.is_(None),
|
|
||||||
User.rank == UserRank.NOT_JOINED).all()
|
|
||||||
for user in users:
|
|
||||||
db.session.delete(user)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@action("Send Video URL notification")
|
|
||||||
def remind_video_url():
|
|
||||||
users = User.query.filter(User.maintained_packages.any(
|
|
||||||
and_(Package.video_url.is_(None), Package.type==PackageType.GAME, Package.state==PackageState.APPROVED)))
|
|
||||||
system_user = get_system_user()
|
|
||||||
for user in users:
|
|
||||||
packages = db.session.query(Package.title).filter(
|
|
||||||
or_(Package.author==user, Package.maintainers.any(User.id==user.id)),
|
|
||||||
Package.video_url.is_(None),
|
|
||||||
Package.type == PackageType.GAME,
|
|
||||||
Package.state == PackageState.APPROVED) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
packages = [pkg[0] for pkg in packages]
|
|
||||||
packages_list = _package_list(packages)
|
|
||||||
|
|
||||||
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
|
|
||||||
f"You should add a video to {packages_list}",
|
|
||||||
url_for('users.profile', username=user.username))
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@action("Update screenshot sizes")
|
|
||||||
def update_screenshot_sizes():
|
|
||||||
import sys
|
|
||||||
|
|
||||||
for screenshot in PackageScreenshot.query.all():
|
|
||||||
width, height = get_image_size(screenshot.file_path)
|
|
||||||
print(f"{screenshot.url}: {width}, {height}", file=sys.stderr)
|
|
||||||
screenshot.width = width
|
|
||||||
screenshot.height = height
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@action("Detect game support")
|
|
||||||
def detect_game_support():
|
|
||||||
resolver = GameSupportResolver()
|
|
||||||
resolver.update_all()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@action("Send pending notif digests")
|
|
||||||
def do_send_pending_digests():
|
|
||||||
send_pending_digests.delay()
|
|
|
@ -1,29 +1,32 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import redirect, render_template, url_for, request, flash
|
|
||||||
from flask_login import current_user, login_user
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import StringField, SubmitField
|
|
||||||
from wtforms.validators import InputRequired, Length
|
|
||||||
from app.utils import rank_required, addAuditLog, addNotification, get_system_user
|
|
||||||
from . import bp
|
|
||||||
from .actions import actions
|
|
||||||
from ...models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType
|
|
||||||
|
|
||||||
|
from flask import *
|
||||||
|
from flask_user import *
|
||||||
|
import flask_menu as menu
|
||||||
|
from . import bp
|
||||||
|
from app.models import *
|
||||||
|
from celery import uuid, group
|
||||||
|
from app.tasks.importtasks import importRepoScreenshot, makeVCSRelease, checkZipRelease, updateMetaFromRelease, importForeignDownloads
|
||||||
|
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import *
|
||||||
|
from app.utils import loginUser, rank_required, addNotification
|
||||||
|
import datetime, os
|
||||||
|
|
||||||
@bp.route("/admin/", methods=["GET", "POST"])
|
@bp.route("/admin/", methods=["GET", "POST"])
|
||||||
@rank_required(UserRank.ADMIN)
|
@rank_required(UserRank.ADMIN)
|
||||||
|
@ -31,26 +34,127 @@ def admin_page():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
action = request.form["action"]
|
action = request.form["action"]
|
||||||
|
|
||||||
if action == "restore":
|
if action == "delstuckreleases":
|
||||||
|
PackageRelease.query.filter(PackageRelease.task_id != None).delete()
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(url_for("admin.admin_page"))
|
||||||
|
|
||||||
|
elif action == "checkreleases":
|
||||||
|
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for release in releases:
|
||||||
|
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||||
|
tasks.append(checkZipRelease.s(release.id, zippath))
|
||||||
|
|
||||||
|
result = group(tasks).apply_async()
|
||||||
|
|
||||||
|
while not result.ready():
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
return redirect(url_for("todo.view"))
|
||||||
|
|
||||||
|
elif action == "reimportpackages":
|
||||||
|
tasks = []
|
||||||
|
for package in Package.query.filter_by(approved=True, soft_deleted=False).all():
|
||||||
|
release = package.releases.first()
|
||||||
|
if release:
|
||||||
|
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||||
|
tasks.append(updateMetaFromRelease.s(release.id, zippath))
|
||||||
|
|
||||||
|
result = group(tasks).apply_async()
|
||||||
|
|
||||||
|
while not result.ready():
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
return redirect(url_for("todo.view"))
|
||||||
|
|
||||||
|
elif action == "importforeign":
|
||||||
|
releases = PackageRelease.query.filter(PackageRelease.url.like("http%")).all()
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for release in releases:
|
||||||
|
tasks.append(importForeignDownloads.s(release.id))
|
||||||
|
|
||||||
|
result = group(tasks).apply_async()
|
||||||
|
|
||||||
|
while not result.ready():
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
return redirect(url_for("todo.view"))
|
||||||
|
|
||||||
|
elif action == "importmodlist":
|
||||||
|
task = importTopicList.delay()
|
||||||
|
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
|
||||||
|
|
||||||
|
elif action == "checkusers":
|
||||||
|
task = checkAllForumAccounts.delay()
|
||||||
|
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
|
||||||
|
|
||||||
|
elif action == "importscreenshots":
|
||||||
|
packages = Package.query \
|
||||||
|
.filter_by(soft_deleted=False) \
|
||||||
|
.outerjoin(PackageScreenshot, Package.id==PackageScreenshot.package_id) \
|
||||||
|
.filter(PackageScreenshot.id==None) \
|
||||||
|
.all()
|
||||||
|
for package in packages:
|
||||||
|
importRepoScreenshot.delay(package.id)
|
||||||
|
|
||||||
|
return redirect(url_for("admin.admin_page"))
|
||||||
|
|
||||||
|
elif action == "restore":
|
||||||
package = Package.query.get(request.form["package"])
|
package = Package.query.get(request.form["package"])
|
||||||
if package is None:
|
if package is None:
|
||||||
flash("Unknown package", "danger")
|
flash("Unknown package", "danger")
|
||||||
else:
|
else:
|
||||||
package.state = PackageState.READY_FOR_REVIEW
|
package.soft_deleted = False
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(url_for("admin.admin_page"))
|
return redirect(url_for("admin.admin_page"))
|
||||||
|
|
||||||
elif action in actions:
|
elif action == "recalcscores":
|
||||||
ret = actions[action]["func"]()
|
for p in Package.query.all():
|
||||||
if ret:
|
p.recalcScore()
|
||||||
return ret
|
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(url_for("admin.admin_page"))
|
||||||
|
|
||||||
|
elif action == "cleanuploads":
|
||||||
|
upload_dir = app.config['UPLOAD_DIR']
|
||||||
|
|
||||||
|
(_, _, filenames) = next(os.walk(upload_dir))
|
||||||
|
existing_uploads = set(filenames)
|
||||||
|
|
||||||
|
if len(existing_uploads) != 0:
|
||||||
|
def getURLsFromDB(column):
|
||||||
|
results = db.session.query(column).filter(column != None, column != "").all()
|
||||||
|
return set([os.path.basename(x[0]) for x in results])
|
||||||
|
|
||||||
|
release_urls = getURLsFromDB(PackageRelease.url)
|
||||||
|
screenshot_urls = getURLsFromDB(PackageScreenshot.url)
|
||||||
|
|
||||||
|
db_urls = release_urls.union(screenshot_urls)
|
||||||
|
unreachable = existing_uploads.difference(db_urls)
|
||||||
|
|
||||||
|
import sys
|
||||||
|
print("On Disk: ", existing_uploads, file=sys.stderr)
|
||||||
|
print("In DB: ", db_urls, file=sys.stderr)
|
||||||
|
print("Unreachable: ", unreachable, file=sys.stderr)
|
||||||
|
|
||||||
|
for filename in unreachable:
|
||||||
|
os.remove(os.path.join(upload_dir, filename))
|
||||||
|
|
||||||
|
flash("Deleted " + str(len(unreachable)) + " unreachable uploads", "success")
|
||||||
|
else:
|
||||||
|
flash("No downloads to create", "danger")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
flash("Unknown action: " + action, "danger")
|
flash("Unknown action: " + action, "danger")
|
||||||
|
|
||||||
deleted_packages = Package.query.filter(Package.state == PackageState.DELETED).all()
|
deleted_packages = Package.query.filter_by(soft_deleted=True).all()
|
||||||
return render_template("admin/list.html", deleted_packages=deleted_packages, actions=actions)
|
return render_template("admin/list.html", deleted_packages=deleted_packages)
|
||||||
|
|
||||||
|
|
||||||
class SwitchUserForm(FlaskForm):
|
class SwitchUserForm(FlaskForm):
|
||||||
username = StringField("Username")
|
username = StringField("Username")
|
||||||
|
@ -61,70 +165,15 @@ class SwitchUserForm(FlaskForm):
|
||||||
@rank_required(UserRank.ADMIN)
|
@rank_required(UserRank.ADMIN)
|
||||||
def switch_user():
|
def switch_user():
|
||||||
form = SwitchUserForm(formdata=request.form)
|
form = SwitchUserForm(formdata=request.form)
|
||||||
if form.validate_on_submit():
|
if request.method == "POST" and form.validate():
|
||||||
user = User.query.filter_by(username=form["username"].data).first()
|
user = User.query.filter_by(username=form["username"].data).first()
|
||||||
if user is None:
|
if user is None:
|
||||||
flash("Unable to find user", "danger")
|
flash("Unable to find user", "danger")
|
||||||
elif login_user(user):
|
elif loginUser(user):
|
||||||
return redirect(url_for("users.profile", username=current_user.username))
|
return redirect(url_for("users.profile", username=current_user.username))
|
||||||
else:
|
else:
|
||||||
flash("Unable to login as user", "danger")
|
flash("Unable to login as user", "danger")
|
||||||
|
|
||||||
|
|
||||||
# Process GET or invalid POST
|
# Process GET or invalid POST
|
||||||
return render_template("admin/switch_user.html", form=form)
|
return render_template("admin/switch_user.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
class SendNotificationForm(FlaskForm):
|
|
||||||
title = StringField("Title", [InputRequired(), Length(1, 300)])
|
|
||||||
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
|
|
||||||
submit = SubmitField("Send")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/send-notification/", methods=["GET", "POST"])
|
|
||||||
@rank_required(UserRank.ADMIN)
|
|
||||||
def send_bulk_notification():
|
|
||||||
form = SendNotificationForm(request.form)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
|
||||||
"Sent bulk notification", url_for("admin.admin_page"), None, form.title.data)
|
|
||||||
|
|
||||||
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
|
|
||||||
addNotification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for("admin.admin_page"))
|
|
||||||
|
|
||||||
return render_template("admin/send_bulk_notification.html", form=form)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/restore/", methods=["GET", "POST"])
|
|
||||||
@rank_required(UserRank.EDITOR)
|
|
||||||
def restore():
|
|
||||||
if request.method == "POST":
|
|
||||||
target = request.form["submit"]
|
|
||||||
if "Review" in target:
|
|
||||||
target = PackageState.READY_FOR_REVIEW
|
|
||||||
elif "Changes" in target:
|
|
||||||
target = PackageState.CHANGES_NEEDED
|
|
||||||
else:
|
|
||||||
target = PackageState.WIP
|
|
||||||
|
|
||||||
package = Package.query.get(request.form["package"])
|
|
||||||
if package is None:
|
|
||||||
flash("Unknown package", "danger")
|
|
||||||
else:
|
|
||||||
package.state = target
|
|
||||||
|
|
||||||
addAuditLog(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}",
|
|
||||||
package.getURL("packages.view"), package)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(package.getURL("packages.view"))
|
|
||||||
|
|
||||||
deleted_packages = Package.query \
|
|
||||||
.filter(Package.state == PackageState.DELETED) \
|
|
||||||
.join(Package.author) \
|
|
||||||
.order_by(db.asc(User.username), db.asc(Package.name)) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
return render_template("admin/restore.html", deleted_packages=deleted_packages)
|
|
||||||
|
|
|
@ -2,21 +2,23 @@
|
||||||
# Copyright (C) 2020 rubenwardy
|
# Copyright (C) 2020 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import render_template, request, abort
|
|
||||||
from app.models import db, AuditLogEntry, UserRank, User
|
from flask import Blueprint, render_template, redirect, url_for
|
||||||
from app.utils import rank_required, get_int_or_abort
|
from flask_user import current_user
|
||||||
|
from app.models import db, AuditLogEntry, UserRank
|
||||||
|
from app.utils import rank_required
|
||||||
|
|
||||||
from . import bp
|
from . import bp
|
||||||
|
|
||||||
|
@ -24,23 +26,12 @@ from . import bp
|
||||||
@bp.route("/admin/audit/")
|
@bp.route("/admin/audit/")
|
||||||
@rank_required(UserRank.MODERATOR)
|
@rank_required(UserRank.MODERATOR)
|
||||||
def audit():
|
def audit():
|
||||||
page = get_int_or_abort(request.args.get("page"), 1)
|
log = AuditLogEntry.query.order_by(db.desc(AuditLogEntry.created_at)).all()
|
||||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
return render_template("admin/audit.html", log=log)
|
||||||
|
|
||||||
query = AuditLogEntry.query.order_by(db.desc(AuditLogEntry.created_at))
|
|
||||||
|
|
||||||
if "username" in request.args:
|
|
||||||
user = User.query.filter_by(username=request.args.get("username")).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
query = query.filter_by(causer=user)
|
|
||||||
|
|
||||||
pagination = query.paginate(page, num, True)
|
|
||||||
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/audit/<int:id_>/")
|
@bp.route("/admin/audit/<int:id>/")
|
||||||
@rank_required(UserRank.MODERATOR)
|
@rank_required(UserRank.MODERATOR)
|
||||||
def audit_view(id_):
|
def audit_view(id):
|
||||||
entry = AuditLogEntry.query.get(id_)
|
entry = AuditLogEntry.query.get(id)
|
||||||
return render_template("admin/audit_view.html", entry=entry)
|
return render_template("admin/audit_view.html", entry=entry)
|
||||||
|
|
|
@ -1,78 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2020 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from flask import request, abort, url_for, redirect, render_template, flash
|
|
||||||
from flask_login import current_user
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import TextAreaField, SubmitField, StringField
|
|
||||||
from wtforms.validators import InputRequired, Length
|
|
||||||
|
|
||||||
from app.markdown import render_markdown
|
|
||||||
from app.tasks.emails import send_user_email
|
|
||||||
from app.utils import rank_required, addAuditLog
|
|
||||||
from . import bp
|
|
||||||
from ...models import UserRank, User, AuditSeverity
|
|
||||||
|
|
||||||
|
|
||||||
class SendEmailForm(FlaskForm):
|
|
||||||
subject = StringField("Subject", [InputRequired(), Length(1, 300)])
|
|
||||||
text = TextAreaField("Message", [InputRequired()])
|
|
||||||
submit = SubmitField("Send")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/send-email/", methods=["GET", "POST"])
|
|
||||||
@rank_required(UserRank.ADMIN)
|
|
||||||
def send_single_email():
|
|
||||||
username = request.args["username"]
|
|
||||||
user = User.query.filter_by(username=username).first()
|
|
||||||
if user is None:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
next_url = url_for("users.profile", username=user.username)
|
|
||||||
|
|
||||||
if user.email is None:
|
|
||||||
flash("User has no email address!", "danger")
|
|
||||||
return redirect(next_url)
|
|
||||||
|
|
||||||
form = SendEmailForm(request.form)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
|
||||||
"Sent email to {}".format(user.display_name), url_for("users.profile", username=username))
|
|
||||||
|
|
||||||
text = form.text.data
|
|
||||||
html = render_markdown(text)
|
|
||||||
task = send_user_email.delay(user.email, user.locale or "en",form.subject.data, text, html)
|
|
||||||
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
|
||||||
|
|
||||||
return render_template("admin/send_email.html", form=form, user=user)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/send-bulk-email/", methods=["GET", "POST"])
|
|
||||||
@rank_required(UserRank.ADMIN)
|
|
||||||
def send_bulk_email():
|
|
||||||
form = SendEmailForm(request.form)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user,
|
|
||||||
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
|
|
||||||
|
|
||||||
text = form.text.data
|
|
||||||
html = render_markdown(text)
|
|
||||||
for user in User.query.filter(User.email.isnot(None)).all():
|
|
||||||
send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html)
|
|
||||||
|
|
||||||
return redirect(url_for("admin.admin_page"))
|
|
||||||
|
|
||||||
return render_template("admin/send_bulk_email.html", form=form)
|
|
|
@ -1,42 +1,38 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask import redirect, render_template, abort, url_for, request, flash
|
from flask import *
|
||||||
from flask_wtf import FlaskForm
|
from flask_user import *
|
||||||
from wtforms import StringField, BooleanField, SubmitField, URLField
|
|
||||||
from wtforms.validators import InputRequired, Length, Optional
|
|
||||||
|
|
||||||
from app.utils import rank_required, nonEmptyOrNone
|
|
||||||
from . import bp
|
from . import bp
|
||||||
from ...models import UserRank, License, db
|
from app.models import *
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import *
|
||||||
|
from wtforms.validators import *
|
||||||
|
from app.utils import rank_required
|
||||||
|
|
||||||
@bp.route("/licenses/")
|
@bp.route("/licenses/")
|
||||||
@rank_required(UserRank.MODERATOR)
|
@rank_required(UserRank.MODERATOR)
|
||||||
def license_list():
|
def license_list():
|
||||||
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
|
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
|
||||||
|
|
||||||
|
|
||||||
class LicenseForm(FlaskForm):
|
class LicenseForm(FlaskForm):
|
||||||
name = StringField("Name", [InputRequired(), Length(3, 100)])
|
name = StringField("Name", [InputRequired(), Length(3,100)])
|
||||||
is_foss = BooleanField("Is FOSS")
|
is_foss = BooleanField("Is FOSS")
|
||||||
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
|
submit = SubmitField("Save")
|
||||||
submit = SubmitField("Save")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/licenses/new/", methods=["GET", "POST"])
|
@bp.route("/licenses/new/", methods=["GET", "POST"])
|
||||||
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])
|
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])
|
||||||
|
@ -51,7 +47,7 @@ def create_edit_license(name=None):
|
||||||
form = LicenseForm(formdata=request.form, obj=license)
|
form = LicenseForm(formdata=request.form, obj=license)
|
||||||
if request.method == "GET" and license is None:
|
if request.method == "GET" and license is None:
|
||||||
form.is_foss.data = True
|
form.is_foss.data = True
|
||||||
elif form.validate_on_submit():
|
elif request.method == "POST" and form.validate():
|
||||||
if license is None:
|
if license is None:
|
||||||
license = License(form.name.data)
|
license = License(form.name.data)
|
||||||
db.session.add(license)
|
db.session.add(license)
|
||||||
|
|
|
@ -1,57 +1,42 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask import redirect, render_template, abort, url_for, request
|
from flask import *
|
||||||
from flask_login import current_user, login_required
|
from flask_user import *
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
|
|
||||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
|
||||||
|
|
||||||
from . import bp
|
from . import bp
|
||||||
from ...models import Permission, Tag, db
|
from app.models import *
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import *
|
||||||
|
from wtforms.validators import *
|
||||||
|
from app.utils import rank_required
|
||||||
|
|
||||||
@bp.route("/tags/")
|
@bp.route("/tags/")
|
||||||
@login_required
|
@rank_required(UserRank.MODERATOR)
|
||||||
def tag_list():
|
def tag_list():
|
||||||
if not Permission.EDIT_TAGS.check(current_user):
|
return render_template("admin/tags/list.html", tags=Tag.query.order_by(db.asc(Tag.title)).all())
|
||||||
abort(403)
|
|
||||||
|
|
||||||
query = Tag.query
|
|
||||||
|
|
||||||
if request.args.get("sort") == "views":
|
|
||||||
query = query.order_by(db.desc(Tag.views))
|
|
||||||
else:
|
|
||||||
query = query.order_by(db.asc(Tag.title))
|
|
||||||
|
|
||||||
return render_template("admin/tags/list.html", tags=query.all())
|
|
||||||
|
|
||||||
|
|
||||||
class TagForm(FlaskForm):
|
class TagForm(FlaskForm):
|
||||||
title = StringField("Title", [InputRequired(), Length(3, 100)])
|
title = StringField("Title", [InputRequired(), Length(3,100)])
|
||||||
description = TextAreaField("Description", [Optional(), Length(0, 500)])
|
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||||
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
submit = SubmitField("Save")
|
||||||
is_protected = BooleanField("Is Protected")
|
|
||||||
submit = SubmitField("Save")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/tags/new/", methods=["GET", "POST"])
|
@bp.route("/tags/new/", methods=["GET", "POST"])
|
||||||
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
|
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
|
||||||
@login_required
|
@rank_required(UserRank.MODERATOR)
|
||||||
def create_edit_tag(name=None):
|
def create_edit_tag(name=None):
|
||||||
tag = None
|
tag = None
|
||||||
if name is not None:
|
if name is not None:
|
||||||
|
@ -59,24 +44,14 @@ def create_edit_tag(name=None):
|
||||||
if tag is None:
|
if tag is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if not Permission.checkPerm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
|
form = TagForm(formdata=request.form, obj=tag)
|
||||||
abort(403)
|
if request.method == "POST" and form.validate():
|
||||||
|
|
||||||
form = TagForm( obj=tag)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
if tag is None:
|
if tag is None:
|
||||||
tag = Tag(form.title.data)
|
tag = Tag(form.title.data)
|
||||||
tag.description = form.description.data
|
|
||||||
tag.is_protected = form.is_protected.data
|
|
||||||
db.session.add(tag)
|
db.session.add(tag)
|
||||||
else:
|
else:
|
||||||
form.populate_obj(tag)
|
form.populate_obj(tag)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
return redirect(url_for("admin.create_edit_tag", name=tag.name))
|
||||||
if Permission.EDIT_TAGS.check(current_user):
|
|
||||||
return redirect(url_for("admin.create_edit_tag", name=tag.name))
|
|
||||||
else:
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
return render_template("admin/tags/edit.html", tag=tag, form=form)
|
return render_template("admin/tags/edit.html", tag=tag, form=form)
|
||||||
|
|
|
@ -1,41 +1,38 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask import redirect, render_template, abort, url_for, request, flash
|
from flask import *
|
||||||
from flask_wtf import FlaskForm
|
from flask_user import *
|
||||||
from wtforms import StringField, IntegerField, SubmitField
|
|
||||||
from wtforms.validators import InputRequired, Length
|
|
||||||
|
|
||||||
from app.utils import rank_required
|
|
||||||
from . import bp
|
from . import bp
|
||||||
from ...models import UserRank, MinetestRelease, db
|
from app.models import *
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import *
|
||||||
|
from wtforms.validators import *
|
||||||
|
from app.utils import rank_required
|
||||||
|
|
||||||
@bp.route("/versions/")
|
@bp.route("/versions/")
|
||||||
@rank_required(UserRank.MODERATOR)
|
@rank_required(UserRank.MODERATOR)
|
||||||
def version_list():
|
def version_list():
|
||||||
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
|
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
|
||||||
|
|
||||||
|
|
||||||
class VersionForm(FlaskForm):
|
class VersionForm(FlaskForm):
|
||||||
name = StringField("Name", [InputRequired(), Length(3, 100)])
|
name = StringField("Name", [InputRequired(), Length(3,100)])
|
||||||
protocol = IntegerField("Protocol")
|
protocol = IntegerField("Protocol")
|
||||||
submit = SubmitField("Save")
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/versions/new/", methods=["GET", "POST"])
|
@bp.route("/versions/new/", methods=["GET", "POST"])
|
||||||
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"])
|
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"])
|
||||||
|
@ -48,7 +45,7 @@ def create_edit_version(name=None):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
form = VersionForm(formdata=request.form, obj=version)
|
form = VersionForm(formdata=request.form, obj=version)
|
||||||
if form.validate_on_submit():
|
if request.method == "POST" and form.validate():
|
||||||
if version is None:
|
if version is None:
|
||||||
version = MinetestRelease(form.name.data)
|
version = MinetestRelease(form.name.data)
|
||||||
db.session.add(version)
|
db.session.add(version)
|
||||||
|
|
|
@ -1,63 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2020 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
from flask import redirect, render_template, abort, url_for, request, flash
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import StringField, TextAreaField, SubmitField
|
|
||||||
from wtforms.validators import InputRequired, Length, Optional, Regexp
|
|
||||||
|
|
||||||
from app.utils import rank_required
|
|
||||||
from . import bp
|
|
||||||
from ...models import UserRank, ContentWarning, db
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/warnings/")
|
|
||||||
@rank_required(UserRank.ADMIN)
|
|
||||||
def warning_list():
|
|
||||||
return render_template("admin/warnings/list.html", warnings=ContentWarning.query.order_by(db.asc(ContentWarning.title)).all())
|
|
||||||
|
|
||||||
|
|
||||||
class WarningForm(FlaskForm):
|
|
||||||
title = StringField("Title", [InputRequired(), Length(3, 100)])
|
|
||||||
description = TextAreaField("Description", [Optional(), Length(0, 500)])
|
|
||||||
name = StringField("Name", [Optional(), Length(1, 20),
|
|
||||||
Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
|
||||||
submit = SubmitField("Save")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/admin/warnings/new/", methods=["GET", "POST"])
|
|
||||||
@bp.route("/admin/warnings/<name>/edit/", methods=["GET", "POST"])
|
|
||||||
@rank_required(UserRank.ADMIN)
|
|
||||||
def create_edit_warning(name=None):
|
|
||||||
warning = None
|
|
||||||
if name is not None:
|
|
||||||
warning = ContentWarning.query.filter_by(name=name).first()
|
|
||||||
if warning is None:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
form = WarningForm(formdata=request.form, obj=warning)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
if warning is None:
|
|
||||||
warning = ContentWarning(form.title.data, form.description.data)
|
|
||||||
db.session.add(warning)
|
|
||||||
else:
|
|
||||||
form.populate_obj(warning)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for("admin.warning_list"))
|
|
||||||
|
|
||||||
return render_template("admin/warnings/edit.html", warning=warning, form=form)
|
|
|
@ -1,17 +1,17 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
|
@ -2,25 +2,22 @@
|
||||||
# Copyright (C) 2019 rubenwardy
|
# Copyright (C) 2019 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from functools import wraps
|
from flask import request, make_response, jsonify, abort
|
||||||
|
|
||||||
from flask import request, abort
|
|
||||||
|
|
||||||
from app.models import APIToken
|
from app.models import APIToken
|
||||||
from .support import error
|
from .support import error
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
def is_api_authd(f):
|
def is_api_authd(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
|
|
|
@ -1,87 +1,58 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import math
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
import flask_sqlalchemy
|
from flask import *
|
||||||
from flask import request, jsonify, current_app
|
from flask_user import *
|
||||||
from flask_login import current_user, login_required
|
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
from sqlalchemy.sql.expression import func
|
|
||||||
|
|
||||||
from app import csrf
|
|
||||||
from app.markdown import render_markdown
|
|
||||||
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \
|
|
||||||
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread
|
|
||||||
from app.querybuilder import QueryBuilder
|
|
||||||
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes
|
|
||||||
from . import bp
|
from . import bp
|
||||||
from .auth import is_api_authd
|
from .auth import is_api_authd
|
||||||
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
|
from .support import error, handleCreateRelease
|
||||||
api_order_screenshots, api_edit_package, api_set_cover_image
|
from app import csrf
|
||||||
from functools import wraps
|
from app.models import *
|
||||||
|
from app.utils import is_package_page
|
||||||
|
from app.markdown import render_markdown
|
||||||
def cors_allowed(f):
|
from app.querybuilder import QueryBuilder
|
||||||
@wraps(f)
|
|
||||||
def inner(*args, **kwargs):
|
|
||||||
res = f(*args, **kwargs)
|
|
||||||
res.headers["Access-Control-Allow-Origin"] = "*"
|
|
||||||
res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
|
||||||
res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
|
||||||
return res
|
|
||||||
return inner
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/")
|
@bp.route("/api/packages/")
|
||||||
@cors_allowed
|
|
||||||
def packages():
|
def packages():
|
||||||
qb = QueryBuilder(request.args)
|
qb = QueryBuilder(request.args)
|
||||||
query = qb.buildPackageQuery()
|
query = qb.buildPackageQuery()
|
||||||
|
ver = qb.getMinetestVersion()
|
||||||
|
|
||||||
if request.args.get("fmt") == "keys":
|
pkgs = [package.getAsDictionaryShort(current_app.config["BASE_URL"], version=ver) \
|
||||||
return jsonify([package.getAsDictionaryKey() for package in query.all()])
|
for package in query.all()]
|
||||||
|
return jsonify([package for package in pkgs if package.get("release")])
|
||||||
|
|
||||||
pkgs = qb.convertToDictionary(query.all())
|
|
||||||
if "engine_version" in request.args or "protocol_version" in request.args:
|
@bp.route("/api/scores/")
|
||||||
pkgs = [package for package in pkgs if package.get("release")]
|
def package_scores():
|
||||||
|
qb = QueryBuilder(request.args)
|
||||||
|
query = qb.buildPackageQuery()
|
||||||
|
|
||||||
|
pkgs = [package.getScoreDict() for package in query.all()]
|
||||||
return jsonify(pkgs)
|
return jsonify(pkgs)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/")
|
@bp.route("/api/packages/<author>/<name>/")
|
||||||
@is_package_page
|
@is_package_page
|
||||||
@cors_allowed
|
|
||||||
def package(package):
|
def package(package):
|
||||||
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
|
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
|
def resolve_package_deps(out, package, only_hard):
|
||||||
@csrf.exempt
|
|
||||||
@is_package_page
|
|
||||||
@is_api_authd
|
|
||||||
@cors_allowed
|
|
||||||
def edit_package(token, package):
|
|
||||||
if not token:
|
|
||||||
error(401, "Authentication needed")
|
|
||||||
|
|
||||||
return api_edit_package(token, package, request.json)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_package_deps(out, package, only_hard, depth=1):
|
|
||||||
id = package.getId()
|
id = package.getId()
|
||||||
if id in out:
|
if id in out:
|
||||||
return
|
return
|
||||||
|
@ -89,29 +60,25 @@ def resolve_package_deps(out, package, only_hard, depth=1):
|
||||||
ret = []
|
ret = []
|
||||||
out[id] = ret
|
out[id] = ret
|
||||||
|
|
||||||
if package.type != PackageType.MOD:
|
|
||||||
return
|
|
||||||
|
|
||||||
for dep in package.dependencies:
|
for dep in package.dependencies:
|
||||||
if only_hard and dep.optional:
|
if only_hard and dep.optional:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
name = None
|
||||||
|
fulfilled_by = None
|
||||||
|
|
||||||
if dep.package:
|
if dep.package:
|
||||||
name = dep.package.name
|
name = dep.package.name
|
||||||
fulfilled_by = [ dep.package.getId() ]
|
fulfilled_by = [ dep.package.getId() ]
|
||||||
resolve_package_deps(out, dep.package, only_hard, depth)
|
resolve_package_deps(out, dep.package, only_hard)
|
||||||
|
|
||||||
elif dep.meta_package:
|
elif dep.meta_package:
|
||||||
name = dep.meta_package.name
|
name = dep.meta_package.name
|
||||||
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
|
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
|
||||||
|
# TODO: resolve most likely candidate
|
||||||
if depth == 1 and not dep.optional:
|
|
||||||
most_likely = next((pkg for pkg in dep.meta_package.packages if pkg.type == PackageType.MOD), None)
|
|
||||||
if most_likely:
|
|
||||||
resolve_package_deps(out, most_likely, only_hard, depth + 1)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise Exception("Malformed dependency")
|
raise "Malformed dependency"
|
||||||
|
|
||||||
ret.append({
|
ret.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
|
@ -122,7 +89,6 @@ def resolve_package_deps(out, package, only_hard, depth=1):
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/dependencies/")
|
@bp.route("/api/packages/<author>/<name>/dependencies/")
|
||||||
@is_package_page
|
@is_package_page
|
||||||
@cors_allowed
|
|
||||||
def package_dependencies(package):
|
def package_dependencies(package):
|
||||||
only_hard = request.args.get("only_hard")
|
only_hard = request.args.get("only_hard")
|
||||||
|
|
||||||
|
@ -132,8 +98,14 @@ def package_dependencies(package):
|
||||||
return jsonify(out)
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/packages/<author>/<name>/releases/")
|
||||||
|
@is_package_page
|
||||||
|
def list_releases(package):
|
||||||
|
releases = package.releases.filter_by(approved=True).all()
|
||||||
|
return jsonify([ rel.getAsDictionary() for rel in releases ])
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/topics/")
|
@bp.route("/api/topics/")
|
||||||
@cors_allowed
|
|
||||||
def topics():
|
def topics():
|
||||||
qb = QueryBuilder(request.args)
|
qb = QueryBuilder(request.args)
|
||||||
query = qb.buildTopicQuery(show_added=True)
|
query = qb.buildTopicQuery(show_added=True)
|
||||||
|
@ -146,11 +118,11 @@ def topic_set_discard():
|
||||||
tid = request.args.get("tid")
|
tid = request.args.get("tid")
|
||||||
discard = request.args.get("discard")
|
discard = request.args.get("discard")
|
||||||
if tid is None or discard is None:
|
if tid is None or discard is None:
|
||||||
error(400, "Missing topic ID or discard bool")
|
abort(400)
|
||||||
|
|
||||||
topic = ForumTopic.query.get(tid)
|
topic = ForumTopic.query.get(tid)
|
||||||
if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
|
if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
|
||||||
error(403, "Permission denied, need: TOPIC_DISCARD")
|
abort(403)
|
||||||
|
|
||||||
topic.discarded = discard == "true"
|
topic.discarded = discard == "true"
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
@ -158,9 +130,14 @@ def topic_set_discard():
|
||||||
return jsonify(topic.getAsDictionary())
|
return jsonify(topic.getAsDictionary())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/minetest_versions/")
|
||||||
|
def versions():
|
||||||
|
return jsonify([{ "name": rel.name, "protocol_version": rel.protocol }\
|
||||||
|
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/whoami/")
|
@bp.route("/api/whoami/")
|
||||||
@is_api_authd
|
@is_api_authd
|
||||||
@cors_allowed
|
|
||||||
def whoami(token):
|
def whoami(token):
|
||||||
if token is None:
|
if token is None:
|
||||||
return jsonify({ "is_authenticated": False, "username": None })
|
return jsonify({ "is_authenticated": False, "username": None })
|
||||||
|
@ -174,41 +151,10 @@ def markdown():
|
||||||
return render_markdown(request.data.decode("utf-8"))
|
return render_markdown(request.data.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/releases/")
|
|
||||||
@cors_allowed
|
|
||||||
def list_all_releases():
|
|
||||||
query = PackageRelease.query.filter_by(approved=True) \
|
|
||||||
.filter(PackageRelease.package.has(state=PackageState.APPROVED)) \
|
|
||||||
.order_by(db.desc(PackageRelease.releaseDate))
|
|
||||||
|
|
||||||
if "author" in request.args:
|
|
||||||
author = User.query.filter_by(username=request.args["author"]).first()
|
|
||||||
if author is None:
|
|
||||||
error(404, "Author not found")
|
|
||||||
query = query.filter(PackageRelease.package.has(author=author))
|
|
||||||
|
|
||||||
if "maintainer" in request.args:
|
|
||||||
maintainer = User.query.filter_by(username=request.args["maintainer"]).first()
|
|
||||||
if maintainer is None:
|
|
||||||
error(404, "Maintainer not found")
|
|
||||||
query = query.join(Package)
|
|
||||||
query = query.filter(Package.maintainers.any(id=maintainer.id))
|
|
||||||
|
|
||||||
return jsonify([ rel.getLongAsDictionary() for rel in query.limit(30).all() ])
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/releases/")
|
|
||||||
@is_package_page
|
|
||||||
@cors_allowed
|
|
||||||
def list_releases(package):
|
|
||||||
return jsonify([ rel.getAsDictionary() for rel in package.releases.all() ])
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/releases/new/", methods=["POST"])
|
@bp.route("/api/packages/<author>/<name>/releases/new/", methods=["POST"])
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@is_package_page
|
@is_package_page
|
||||||
@is_api_authd
|
@is_api_authd
|
||||||
@cors_allowed
|
|
||||||
def create_release(token, package):
|
def create_release(token, package):
|
||||||
if not token:
|
if not token:
|
||||||
error(401, "Authentication needed")
|
error(401, "Authentication needed")
|
||||||
|
@ -216,354 +162,15 @@ def create_release(token, package):
|
||||||
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
|
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
|
||||||
error(403, "You do not have the permission to approve releases")
|
error(403, "You do not have the permission to approve releases")
|
||||||
|
|
||||||
data = request.json or request.form
|
|
||||||
if "title" not in data:
|
|
||||||
error(400, "Title is required in the POST data")
|
|
||||||
|
|
||||||
if data.get("method") == "git":
|
|
||||||
for option in ["method", "ref"]:
|
|
||||||
if option not in data:
|
|
||||||
error(400, option + " is required in the POST data")
|
|
||||||
|
|
||||||
return api_create_vcs_release(token, package, data["title"], data["ref"])
|
|
||||||
|
|
||||||
elif request.files:
|
|
||||||
file = request.files.get("file")
|
|
||||||
if file is None:
|
|
||||||
error(400, "Missing 'file' in multipart body")
|
|
||||||
|
|
||||||
commit_hash = data.get("commit")
|
|
||||||
|
|
||||||
return api_create_zip_release(token, package, data["title"], file, None, None, "API", commit_hash)
|
|
||||||
|
|
||||||
else:
|
|
||||||
error(400, "Unknown release-creation method. Specify the method or provide a file.")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/")
|
|
||||||
@is_package_page
|
|
||||||
@cors_allowed
|
|
||||||
def release(package: Package, id: int):
|
|
||||||
release = PackageRelease.query.get(id)
|
|
||||||
if release is None or release.package != package:
|
|
||||||
error(404, "Release not found")
|
|
||||||
|
|
||||||
return jsonify(release.getAsDictionary())
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/", methods=["DELETE"])
|
|
||||||
@csrf.exempt
|
|
||||||
@is_package_page
|
|
||||||
@is_api_authd
|
|
||||||
@cors_allowed
|
|
||||||
def delete_release(token: APIToken, package: Package, id: int):
|
|
||||||
release = PackageRelease.query.get(id)
|
|
||||||
if release is None or release.package != package:
|
|
||||||
error(404, "Release not found")
|
|
||||||
|
|
||||||
if not token:
|
|
||||||
error(401, "Authentication needed")
|
|
||||||
|
|
||||||
if not token.canOperateOnPackage(package):
|
|
||||||
error(403, "API token does not have access to the package")
|
|
||||||
|
|
||||||
if not release.checkPerm(token.owner, Permission.DELETE_RELEASE):
|
|
||||||
error(403, "Unable to delete the release, make sure there's a newer release available")
|
|
||||||
|
|
||||||
db.session.delete(release)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return jsonify({"success": True})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/screenshots/")
|
|
||||||
@is_package_page
|
|
||||||
@cors_allowed
|
|
||||||
def list_screenshots(package):
|
|
||||||
screenshots = package.screenshots.all()
|
|
||||||
return jsonify([ss.getAsDictionary(current_app.config["BASE_URL"]) for ss in screenshots])
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/screenshots/new/", methods=["POST"])
|
|
||||||
@csrf.exempt
|
|
||||||
@is_package_page
|
|
||||||
@is_api_authd
|
|
||||||
@cors_allowed
|
|
||||||
def create_screenshot(token: APIToken, package: Package):
|
|
||||||
if not token:
|
|
||||||
error(401, "Authentication needed")
|
|
||||||
|
|
||||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
|
||||||
error(403, "You do not have the permission to create screenshots")
|
|
||||||
|
|
||||||
data = request.form
|
|
||||||
if "title" not in data:
|
|
||||||
error(400, "Title is required in the POST data")
|
|
||||||
|
|
||||||
file = request.files.get("file")
|
|
||||||
if file is None:
|
|
||||||
error(400, "Missing 'file' in multipart body")
|
|
||||||
|
|
||||||
return api_create_screenshot(token, package, data["title"], file, isYes(data.get("is_cover_image")))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
|
|
||||||
@is_package_page
|
|
||||||
@cors_allowed
|
|
||||||
def screenshot(package, id):
|
|
||||||
ss = PackageScreenshot.query.get(id)
|
|
||||||
if ss is None or ss.package != package:
|
|
||||||
error(404, "Screenshot not found")
|
|
||||||
|
|
||||||
return jsonify(ss.getAsDictionary(current_app.config["BASE_URL"]))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/", methods=["DELETE"])
|
|
||||||
@csrf.exempt
|
|
||||||
@is_package_page
|
|
||||||
@is_api_authd
|
|
||||||
@cors_allowed
|
|
||||||
def delete_screenshot(token: APIToken, package: Package, id: int):
|
|
||||||
ss = PackageScreenshot.query.get(id)
|
|
||||||
if ss is None or ss.package != package:
|
|
||||||
error(404, "Screenshot not found")
|
|
||||||
|
|
||||||
if not token:
|
|
||||||
error(401, "Authentication needed")
|
|
||||||
|
|
||||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
|
||||||
error(403, "You do not have the permission to delete screenshots")
|
|
||||||
|
|
||||||
if not token.canOperateOnPackage(package):
|
|
||||||
error(403, "API token does not have access to the package")
|
|
||||||
|
|
||||||
if package.cover_image == ss:
|
|
||||||
package.cover_image = None
|
|
||||||
db.session.merge(package)
|
|
||||||
|
|
||||||
db.session.delete(ss)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return jsonify({ "success": True })
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/screenshots/order/", methods=["POST"])
|
|
||||||
@csrf.exempt
|
|
||||||
@is_package_page
|
|
||||||
@is_api_authd
|
|
||||||
@cors_allowed
|
|
||||||
def order_screenshots(token: APIToken, package: Package):
|
|
||||||
if not token:
|
|
||||||
error(401, "Authentication needed")
|
|
||||||
|
|
||||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
|
||||||
error(403, "You do not have the permission to change screenshots")
|
|
||||||
|
|
||||||
if not token.canOperateOnPackage(package):
|
|
||||||
error(403, "API token does not have access to the package")
|
|
||||||
|
|
||||||
json = request.json
|
json = request.json
|
||||||
if json is None or not isinstance(json, list):
|
if json is None:
|
||||||
error(400, "Expected order body to be array")
|
error(400, "JSON post data is required")
|
||||||
|
|
||||||
return api_order_screenshots(token, package, request.json)
|
for option in ["method", "title", "ref"]:
|
||||||
|
if json.get(option) is None:
|
||||||
|
error(400, option + " is required in the POST data")
|
||||||
|
|
||||||
|
if json["method"].lower() != "git":
|
||||||
|
error(400, "Release-creation methods other than git are not supported")
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/screenshots/cover-image/", methods=["POST"])
|
return handleCreateRelease(token, package, json["title"], json["ref"])
|
||||||
@csrf.exempt
|
|
||||||
@is_package_page
|
|
||||||
@is_api_authd
|
|
||||||
@cors_allowed
|
|
||||||
def set_cover_image(token: APIToken, package: Package):
|
|
||||||
if not token:
|
|
||||||
error(401, "Authentication needed")
|
|
||||||
|
|
||||||
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
|
|
||||||
error(403, "You do not have the permission to change screenshots")
|
|
||||||
|
|
||||||
if not token.canOperateOnPackage(package):
|
|
||||||
error(403, "API token does not have access to the package")
|
|
||||||
|
|
||||||
json = request.json
|
|
||||||
if json is None or not isinstance(json, dict) or "cover_image" not in json:
|
|
||||||
error(400, "Expected body to be an object with cover_image as a key")
|
|
||||||
|
|
||||||
return api_set_cover_image(token, package, request.json["cover_image"])
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/packages/<author>/<name>/reviews/")
|
|
||||||
@is_package_page
|
|
||||||
@cors_allowed
|
|
||||||
def list_reviews(package):
|
|
||||||
reviews = package.reviews
|
|
||||||
return jsonify([review.getAsDictionary() for review in reviews])
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/reviews/")
|
|
||||||
@cors_allowed
|
|
||||||
def list_all_reviews():
|
|
||||||
page = get_int_or_abort(request.args.get("page"), 1)
|
|
||||||
num = min(get_int_or_abort(request.args.get("n"), 100), 100)
|
|
||||||
|
|
||||||
query = PackageReview.query
|
|
||||||
query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package))
|
|
||||||
|
|
||||||
if request.args.get("author"):
|
|
||||||
query = query.filter(PackageReview.author.has(User.username == request.args.get("author")))
|
|
||||||
|
|
||||||
if request.args.get("is_positive"):
|
|
||||||
query = query.filter(PackageReview.recommends == isYes(request.args.get("is_positive")))
|
|
||||||
|
|
||||||
q = request.args.get("q")
|
|
||||||
if q:
|
|
||||||
query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%")))
|
|
||||||
|
|
||||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
|
|
||||||
return jsonify({
|
|
||||||
"page": pagination.page,
|
|
||||||
"per_page": pagination.per_page,
|
|
||||||
"page_count": math.ceil(pagination.total / pagination.per_page),
|
|
||||||
"total": pagination.total,
|
|
||||||
"urls": {
|
|
||||||
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
|
|
||||||
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
|
|
||||||
},
|
|
||||||
"items": [review.getAsDictionary(True) for review in pagination.items],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/scores/")
|
|
||||||
@cors_allowed
|
|
||||||
def package_scores():
|
|
||||||
qb = QueryBuilder(request.args)
|
|
||||||
query = qb.buildPackageQuery()
|
|
||||||
|
|
||||||
pkgs = [package.getScoreDict() for package in query.all()]
|
|
||||||
return jsonify(pkgs)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/tags/")
|
|
||||||
@cors_allowed
|
|
||||||
def tags():
|
|
||||||
return jsonify([tag.getAsDictionary() for tag in Tag.query.all() ])
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/content_warnings/")
|
|
||||||
@cors_allowed
|
|
||||||
def content_warnings():
|
|
||||||
return jsonify([warning.getAsDictionary() for warning in ContentWarning.query.all() ])
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/licenses/")
|
|
||||||
@cors_allowed
|
|
||||||
def licenses():
|
|
||||||
return jsonify([ { "name": license.name, "is_foss": license.is_foss } \
|
|
||||||
for license in License.query.order_by(db.asc(License.name)).all() ])
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/homepage/")
|
|
||||||
@cors_allowed
|
|
||||||
def homepage():
|
|
||||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
|
||||||
count = query.count()
|
|
||||||
|
|
||||||
featured = query.filter(Package.tags.any(name="featured")).order_by(
|
|
||||||
func.random()).limit(6).all()
|
|
||||||
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
|
|
||||||
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
|
|
||||||
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all()
|
|
||||||
pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(8).all()
|
|
||||||
high_reviewed = query.order_by(db.desc(Package.score - Package.score_downloads)) \
|
|
||||||
.filter(Package.reviews.any()).limit(4).all()
|
|
||||||
|
|
||||||
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
|
|
||||||
.filter_by(state=PackageState.APPROVED) \
|
|
||||||
.order_by(db.desc(PackageRelease.releaseDate)) \
|
|
||||||
.limit(20).all()
|
|
||||||
updated = updated[:4]
|
|
||||||
|
|
||||||
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
|
||||||
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
|
||||||
|
|
||||||
def mapPackages(packages: List[Package]):
|
|
||||||
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"count": count,
|
|
||||||
"downloads": downloads,
|
|
||||||
"featured": mapPackages(featured),
|
|
||||||
"new": mapPackages(new),
|
|
||||||
"updated": mapPackages(updated),
|
|
||||||
"pop_mod": mapPackages(pop_mod),
|
|
||||||
"pop_txp": mapPackages(pop_txp),
|
|
||||||
"pop_game": mapPackages(pop_gam),
|
|
||||||
"high_reviewed": mapPackages(high_reviewed)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/welcome/v1/")
|
|
||||||
@cors_allowed
|
|
||||||
def welcome_v1():
|
|
||||||
featured = Package.query \
|
|
||||||
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
|
|
||||||
Package.tags.any(name="featured")) \
|
|
||||||
.order_by(func.random()) \
|
|
||||||
.limit(5).all()
|
|
||||||
|
|
||||||
mtg = Package.query.filter(Package.author.has(username="Minetest"), Package.name == "minetest_game").one()
|
|
||||||
featured.insert(2, mtg)
|
|
||||||
|
|
||||||
def map_packages(packages: List[Package]):
|
|
||||||
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"featured": map_packages(featured),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/minetest_versions/")
|
|
||||||
@cors_allowed
|
|
||||||
def versions():
|
|
||||||
protocol_version = request.args.get("protocol_version")
|
|
||||||
engine_version = request.args.get("engine_version")
|
|
||||||
if protocol_version or engine_version:
|
|
||||||
rel = MinetestRelease.get(engine_version, get_int_or_abort(protocol_version))
|
|
||||||
if rel is None:
|
|
||||||
error(404, "No releases found")
|
|
||||||
|
|
||||||
return jsonify(rel.getAsDictionary())
|
|
||||||
|
|
||||||
return jsonify([rel.getAsDictionary() \
|
|
||||||
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/dependencies/")
|
|
||||||
@cors_allowed
|
|
||||||
def all_deps():
|
|
||||||
qb = QueryBuilder(request.args)
|
|
||||||
query = qb.buildPackageQuery()
|
|
||||||
|
|
||||||
def format_pkg(pkg: Package):
|
|
||||||
return {
|
|
||||||
"type": pkg.type.toName(),
|
|
||||||
"author": pkg.author.username,
|
|
||||||
"name": pkg.name,
|
|
||||||
"provides": [x.name for x in pkg.provides],
|
|
||||||
"depends": [str(x) for x in pkg.dependencies if not x.optional],
|
|
||||||
"optional_depends": [str(x) for x in pkg.dependencies if x.optional],
|
|
||||||
}
|
|
||||||
|
|
||||||
page = get_int_or_abort(request.args.get("page"), 1)
|
|
||||||
num = min(get_int_or_abort(request.args.get("n"), 100), 300)
|
|
||||||
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
|
|
||||||
return jsonify({
|
|
||||||
"page": pagination.page,
|
|
||||||
"per_page": pagination.per_page,
|
|
||||||
"page_count": math.ceil(pagination.total / pagination.per_page),
|
|
||||||
"total": pagination.total,
|
|
||||||
"urls": {
|
|
||||||
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
|
|
||||||
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
|
|
||||||
},
|
|
||||||
"items": [format_pkg(pkg) for pkg in pagination.items],
|
|
||||||
})
|
|
||||||
|
|
|
@ -1,119 +1,40 @@
|
||||||
# ContentDB
|
from app.models import PackageRelease, db, Permission
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
from app.tasks.importtasks import makeVCSRelease
|
||||||
#
|
from celery import uuid
|
||||||
# This program is free software: you can redistribute it and/or modify
|
from flask import jsonify, abort, make_response, url_for
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
import datetime
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
from flask import jsonify, abort, make_response, url_for, current_app
|
def error(status, message):
|
||||||
|
abort(make_response(jsonify({ "success": False, "error": message }), status))
|
||||||
from app.logic.packages import do_edit_package
|
|
||||||
from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release
|
|
||||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
|
|
||||||
from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
|
|
||||||
|
|
||||||
|
|
||||||
def error(code: int, msg: str):
|
def handleCreateRelease(token, package, title, ref):
|
||||||
abort(make_response(jsonify({ "success": False, "error": msg }), code))
|
|
||||||
|
|
||||||
# Catches LogicErrors and aborts with JSON error
|
|
||||||
def guard(f):
|
|
||||||
def ret(*args, **kwargs):
|
|
||||||
try:
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
except LogicError as e:
|
|
||||||
error(e.code, e.message)
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: str,
|
|
||||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
|
|
||||||
if not token.canOperateOnPackage(package):
|
if not token.canOperateOnPackage(package):
|
||||||
error(403, "API token does not have access to the package")
|
return error(403, "API token does not have access to the package")
|
||||||
|
|
||||||
reason += ", token=" + token.name
|
if not package.checkPerm(token.owner, Permission.MAKE_RELEASE):
|
||||||
|
return error(403, "Permission denied. Missing MAKE_RELEASE permission")
|
||||||
|
|
||||||
rel = guard(do_create_vcs_release)(token.owner, package, title, ref, min_v, max_v, reason)
|
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
|
||||||
|
count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()
|
||||||
|
if count >= 2:
|
||||||
|
return error(429, "Too many requests, please wait before trying again")
|
||||||
|
|
||||||
|
rel = PackageRelease()
|
||||||
|
rel.package = package
|
||||||
|
rel.title = title
|
||||||
|
rel.url = ""
|
||||||
|
rel.task_id = uuid()
|
||||||
|
rel.min_rel = None
|
||||||
|
rel.max_rel = None
|
||||||
|
db.session.add(rel)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
makeVCSRelease.apply_async((rel.id, ref), task_id=rel.task_id)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"task": url_for("tasks.check", id=rel.task_id),
|
"task": url_for("tasks.check", id=rel.task_id),
|
||||||
"release": rel.getAsDictionary()
|
"release": rel.getAsDictionary()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def api_create_zip_release(token: APIToken, package: Package, title: str, file,
|
|
||||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash:str=None):
|
|
||||||
if not token.canOperateOnPackage(package):
|
|
||||||
error(403, "API token does not have access to the package")
|
|
||||||
|
|
||||||
reason += ", token=" + token.name
|
|
||||||
|
|
||||||
rel = guard(do_create_zip_release)(token.owner, package, title, file, min_v, max_v, reason, commit_hash)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"success": True,
|
|
||||||
"task": url_for("tasks.check", id=rel.task_id),
|
|
||||||
"release": rel.getAsDictionary()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, reason="API"):
|
|
||||||
if not token.canOperateOnPackage(package):
|
|
||||||
error(403, "API token does not have access to the package")
|
|
||||||
|
|
||||||
reason += ", token=" + token.name
|
|
||||||
|
|
||||||
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, is_cover_image, reason)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"success": True,
|
|
||||||
"screenshot": ss.getAsDictionary()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def api_order_screenshots(token: APIToken, package: Package, order: [any]):
|
|
||||||
if not token.canOperateOnPackage(package):
|
|
||||||
error(403, "API token does not have access to the package")
|
|
||||||
|
|
||||||
guard(do_order_screenshots)(token.owner, package, order)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"success": True
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def api_set_cover_image(token: APIToken, package: Package, cover_image):
|
|
||||||
if not token.canOperateOnPackage(package):
|
|
||||||
error(403, "API token does not have access to the package")
|
|
||||||
|
|
||||||
guard(do_set_cover_image)(token.owner, package, cover_image)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"success": True
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"):
|
|
||||||
if not token.canOperateOnPackage(package):
|
|
||||||
error(403, "API token does not have access to the package")
|
|
||||||
|
|
||||||
reason += ", token=" + token.name
|
|
||||||
|
|
||||||
package = guard(do_edit_package)(token.owner, package, False, False, data, reason)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"success": True,
|
|
||||||
"package": package.getAsDictionary(current_app.config["BASE_URL"])
|
|
||||||
})
|
|
||||||
|
|
|
@ -1,39 +1,37 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask import render_template, redirect, request, session, url_for, abort
|
from flask import render_template, redirect, request, session, url_for, abort
|
||||||
from flask_babel import lazy_gettext
|
from flask_user import login_required, current_user
|
||||||
from flask_login import login_required, current_user
|
from . import bp
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import *
|
|
||||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
|
||||||
from wtforms.validators import *
|
|
||||||
|
|
||||||
from app.models import db, User, APIToken, Package, Permission
|
from app.models import db, User, APIToken, Package, Permission
|
||||||
from app.utils import randomString
|
from app.utils import randomString
|
||||||
from . import bp
|
from app.querybuilder import QueryBuilder
|
||||||
from ..users.settings import get_setting_tabs
|
|
||||||
|
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import *
|
||||||
|
from wtforms.validators import *
|
||||||
|
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||||
|
|
||||||
class CreateAPIToken(FlaskForm):
|
class CreateAPIToken(FlaskForm):
|
||||||
name = StringField(lazy_gettext("Name"), [InputRequired(), Length(1, 30)])
|
name = StringField("Name", [InputRequired(), Length(1, 30)])
|
||||||
package = QuerySelectField(lazy_gettext("Limit to package"), allow_blank=True,
|
package = QuerySelectField("Limit to package", allow_blank=True, \
|
||||||
get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/tokens/")
|
@bp.route("/user/tokens/")
|
||||||
|
@ -52,7 +50,7 @@ def list_tokens(username):
|
||||||
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
return render_template("api/list_tokens.html", user=user, tabs=get_setting_tabs(user), current_tab="api_tokens")
|
return render_template("api/list_tokens.html", user=user)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/tokens/new/", methods=["GET", "POST"])
|
@bp.route("/users/<username>/tokens/new/", methods=["GET", "POST"])
|
||||||
|
@ -80,9 +78,9 @@ def create_edit_token(username, id=None):
|
||||||
access_token = session.pop("token_" + str(token.id), None)
|
access_token = session.pop("token_" + str(token.id), None)
|
||||||
|
|
||||||
form = CreateAPIToken(formdata=request.form, obj=token)
|
form = CreateAPIToken(formdata=request.form, obj=token)
|
||||||
form.package.query_factory = lambda: user.maintained_packages.all()
|
form.package.query_factory = lambda: Package.query.filter_by(author=user).all()
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if request.method == "POST" and form.validate():
|
||||||
if is_new:
|
if is_new:
|
||||||
token = APIToken()
|
token = APIToken()
|
||||||
token.owner = user
|
token.owner = user
|
||||||
|
|
|
@ -1,32 +1,35 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from flask_babel import gettext
|
|
||||||
|
|
||||||
bp = Blueprint("github", __name__)
|
bp = Blueprint("github", __name__)
|
||||||
|
|
||||||
from flask import redirect, url_for, request, flash, jsonify, current_app
|
from flask import redirect, url_for, request, flash, abort, render_template, jsonify, current_app
|
||||||
from flask_login import current_user
|
from flask_user import current_user, login_required
|
||||||
from sqlalchemy import func, or_, and_
|
from sqlalchemy import func, or_, and_
|
||||||
|
from flask_github import GitHub
|
||||||
from app import github, csrf
|
from app import github, csrf
|
||||||
from app.models import db, User, APIToken, Package, Permission, AuditSeverity
|
from app.models import db, User, APIToken, Package, Permission
|
||||||
from app.utils import abs_url_for, addAuditLog, login_user_set_active
|
from app.utils import loginUser, randomString, abs_url_for
|
||||||
from app.blueprints.api.support import error, api_create_vcs_release
|
from app.blueprints.api.support import error, handleCreateRelease
|
||||||
import hmac, requests
|
import hmac, requests, json
|
||||||
|
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import SelectField, SubmitField
|
||||||
|
|
||||||
@bp.route("/github/start/")
|
@bp.route("/github/start/")
|
||||||
def start():
|
def start():
|
||||||
|
@ -43,8 +46,8 @@ def view_permissions():
|
||||||
def callback(oauth_token):
|
def callback(oauth_token):
|
||||||
next_url = request.args.get("next")
|
next_url = request.args.get("next")
|
||||||
if oauth_token is None:
|
if oauth_token is None:
|
||||||
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
|
flash("Authorization failed [err=gh-oauth-login-failed]", "danger")
|
||||||
return redirect(url_for("users.login"))
|
return redirect(url_for("user.login"))
|
||||||
|
|
||||||
# Get Github username
|
# Get Github username
|
||||||
url = "https://api.github.com/user"
|
url = "https://api.github.com/user"
|
||||||
|
@ -59,27 +62,25 @@ def callback(oauth_token):
|
||||||
if userByGithub is None:
|
if userByGithub is None:
|
||||||
current_user.github_username = username
|
current_user.github_username = username
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(gettext("Linked GitHub to account"), "success")
|
flash("Linked github to account", "success")
|
||||||
return redirect(url_for("homepage.home"))
|
return redirect(url_for("homepage.home"))
|
||||||
else:
|
else:
|
||||||
flash(gettext("GitHub account is already associated with another user"), "danger")
|
flash("Github account is already associated with another user", "danger")
|
||||||
return redirect(url_for("homepage.home"))
|
return redirect(url_for("homepage.home"))
|
||||||
|
|
||||||
# If not logged in, log in
|
# If not logged in, log in
|
||||||
else:
|
else:
|
||||||
if userByGithub is None:
|
if userByGithub is None:
|
||||||
flash(gettext("Unable to find an account for that GitHub user"), "danger")
|
flash("Unable to find an account for that Github user", "danger")
|
||||||
return redirect(url_for("users.claim_forums"))
|
return redirect(url_for("users.claim"))
|
||||||
|
elif loginUser(userByGithub):
|
||||||
ret = login_user_set_active(userByGithub, remember=True)
|
if not current_user.hasPassword():
|
||||||
if ret is None:
|
return redirect(next_url or url_for("users.set_password", optional=True))
|
||||||
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
|
else:
|
||||||
return redirect(url_for("users.login"))
|
return redirect(next_url or url_for("homepage.home"))
|
||||||
|
else:
|
||||||
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
|
flash("Authorization failed [err=gh-login-failed]", "danger")
|
||||||
url_for("users.profile", username=userByGithub.username))
|
return redirect(url_for("user.login"))
|
||||||
db.session.commit()
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/github/webhook/", methods=["POST"])
|
@bp.route("/github/webhook/", methods=["POST"])
|
||||||
|
@ -133,33 +134,140 @@ def webhook():
|
||||||
if event == "push":
|
if event == "push":
|
||||||
ref = json["after"]
|
ref = json["after"]
|
||||||
title = json["head_commit"]["message"].partition("\n")[0]
|
title = json["head_commit"]["message"].partition("\n")[0]
|
||||||
branch = json["ref"].replace("refs/heads/", "")
|
elif event == "create" and json["ref_type"] == "tag":
|
||||||
if branch not in [ "master", "main" ]:
|
|
||||||
return jsonify({ "success": False, "message": "Webhook ignored, as it's not on the master/main branch" })
|
|
||||||
|
|
||||||
elif event == "create":
|
|
||||||
ref_type = json.get("ref_type")
|
|
||||||
if ref_type != "tag":
|
|
||||||
return jsonify({
|
|
||||||
"success": False,
|
|
||||||
"message": "Webhook ignored, as it's a non-tag create event. ref_type='{}'.".format(ref_type)
|
|
||||||
})
|
|
||||||
|
|
||||||
ref = json["ref"]
|
ref = json["ref"]
|
||||||
title = ref
|
title = ref
|
||||||
|
|
||||||
elif event == "ping":
|
elif event == "ping":
|
||||||
return jsonify({ "success": True, "message": "Ping successful" })
|
return jsonify({ "success": True, "message": "Ping successful" })
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
|
return error(400, "Unsupported event. Only 'push', `create:tag`, and 'ping' are supported.")
|
||||||
.format(event or "null"))
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Perform release
|
# Perform release
|
||||||
#
|
#
|
||||||
|
|
||||||
if package.releases.filter_by(commit_hash=ref).count() > 0:
|
return handleCreateRelease(actual_token, package, title, ref)
|
||||||
return
|
|
||||||
|
|
||||||
return api_create_vcs_release(actual_token, package, title, ref, reason="Webhook")
|
|
||||||
|
class SetupWebhookForm(FlaskForm):
|
||||||
|
event = SelectField("Event Type", choices=[('create', 'New tag or GitHub release'), ('push', 'Push')])
|
||||||
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/github/callback/webhook/")
|
||||||
|
@github.authorized_handler
|
||||||
|
def callback_webhook(oauth_token=None):
|
||||||
|
pid = request.args.get("pid")
|
||||||
|
if pid is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
current_user.github_access_token = oauth_token
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return redirect(url_for("github.setup_webhook", pid=pid))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/github/webhook/new/", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def setup_webhook():
|
||||||
|
pid = request.args.get("pid")
|
||||||
|
if pid is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
package = Package.query.get(pid)
|
||||||
|
if package is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not package.checkPerm(current_user, Permission.APPROVE_RELEASE):
|
||||||
|
flash("Only trusted members can use webhooks", "danger")
|
||||||
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
|
gh_user, gh_repo = package.getGitHubFullName()
|
||||||
|
if gh_user is None or gh_repo is None:
|
||||||
|
flash("Unable to get Github full name from repo address", "danger")
|
||||||
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
|
if current_user.github_access_token is None:
|
||||||
|
return github.authorize("write:repo_hook", \
|
||||||
|
redirect_uri=abs_url_for("github.callback_webhook", pid=pid))
|
||||||
|
|
||||||
|
form = SetupWebhookForm(formdata=request.form)
|
||||||
|
if request.method == "POST" and form.validate():
|
||||||
|
token = APIToken()
|
||||||
|
token.name = "GitHub Webhook for " + package.title
|
||||||
|
token.owner = current_user
|
||||||
|
token.access_token = randomString(32)
|
||||||
|
token.package = package
|
||||||
|
|
||||||
|
event = form.event.data
|
||||||
|
if event != "push" and event != "create":
|
||||||
|
abort(500)
|
||||||
|
|
||||||
|
if handleMakeWebhook(gh_user, gh_repo, package, \
|
||||||
|
current_user.github_access_token, event, token):
|
||||||
|
flash("Successfully created webhook", "success")
|
||||||
|
return redirect(package.getDetailsURL())
|
||||||
|
else:
|
||||||
|
return redirect(url_for("github.setup_webhook", pid=package.id))
|
||||||
|
|
||||||
|
return render_template("github/setup_webhook.html", \
|
||||||
|
form=form, package=package)
|
||||||
|
|
||||||
|
|
||||||
|
def handleMakeWebhook(gh_user, gh_repo, package, oauth, event, token):
|
||||||
|
url = "https://api.github.com/repos/{}/{}/hooks".format(gh_user, gh_repo)
|
||||||
|
headers = {
|
||||||
|
"Authorization": "token " + oauth
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"name": "web",
|
||||||
|
"active": True,
|
||||||
|
"events": [event],
|
||||||
|
"config": {
|
||||||
|
"url": abs_url_for("github.webhook"),
|
||||||
|
"content_type": "json",
|
||||||
|
"secret": token.access_token
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# First check that the webhook doesn't already exist
|
||||||
|
r = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if r.status_code == 401 or r.status_code == 403:
|
||||||
|
current_user.github_access_token = None
|
||||||
|
db.session.commit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
flash("Failed to create webhook, received response from Github " +
|
||||||
|
str(r.status_code) + ": " +
|
||||||
|
str(r.json().get("message")), "danger")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for hook in r.json():
|
||||||
|
if hook.get("config") and hook["config"].get("url") and \
|
||||||
|
hook["config"]["url"] == data["config"]["url"]:
|
||||||
|
flash("Failed to create webhook, as it already exists", "danger")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Create it
|
||||||
|
r = requests.post(url, headers=headers, data=json.dumps(data))
|
||||||
|
|
||||||
|
if r.status_code == 201:
|
||||||
|
db.session.add(token)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif r.status_code == 401 or r.status_code == 403:
|
||||||
|
current_user.github_access_token = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
else:
|
||||||
|
flash("Failed to create webhook, received response from Github " +
|
||||||
|
str(r.status_code) + ": " +
|
||||||
|
str(r.json().get("message")), "danger")
|
||||||
|
return False
|
||||||
|
|
|
@ -2,36 +2,37 @@
|
||||||
# Copyright (C) 2020 rubenwardy
|
# Copyright (C) 2020 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request
|
||||||
|
|
||||||
bp = Blueprint("gitlab", __name__)
|
bp = Blueprint("gitlab", __name__)
|
||||||
|
|
||||||
from app import csrf
|
from app import csrf
|
||||||
from app.models import Package, APIToken, Permission
|
from app.models import Package, APIToken, Permission
|
||||||
from app.blueprints.api.support import error, api_create_vcs_release
|
from app.blueprints.api.support import error, handleCreateRelease
|
||||||
|
|
||||||
|
|
||||||
def webhook_impl():
|
@bp.route("/gitlab/webhook/", methods=["POST"])
|
||||||
|
@csrf.exempt
|
||||||
|
def webhook():
|
||||||
json = request.json
|
json = request.json
|
||||||
|
|
||||||
# Get package
|
# Get package
|
||||||
gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "")
|
gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "")
|
||||||
package = Package.query.filter(Package.repo.ilike("%{}%".format(gitlab_url))).first()
|
package = Package.query.filter(Package.repo.ilike("%{}%".format(gitlab_url))).first()
|
||||||
if package is None:
|
if package is None:
|
||||||
return error(400,
|
return error(400, "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url))
|
||||||
"Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url))
|
|
||||||
|
|
||||||
# Get all tokens for package
|
# Get all tokens for package
|
||||||
secret = request.headers.get("X-Gitlab-Token")
|
secret = request.headers.get("X-Gitlab-Token")
|
||||||
|
@ -39,7 +40,7 @@ def webhook_impl():
|
||||||
return error(403, "Token required")
|
return error(403, "Token required")
|
||||||
|
|
||||||
token = APIToken.query.filter_by(access_token=secret).first()
|
token = APIToken.query.filter_by(access_token=secret).first()
|
||||||
if token is None:
|
if secret is None:
|
||||||
return error(403, "Invalid authentication")
|
return error(403, "Invalid authentication")
|
||||||
|
|
||||||
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
|
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
|
||||||
|
@ -53,33 +54,14 @@ def webhook_impl():
|
||||||
if event == "push":
|
if event == "push":
|
||||||
ref = json["after"]
|
ref = json["after"]
|
||||||
title = ref[:5]
|
title = ref[:5]
|
||||||
|
|
||||||
branch = json["ref"].replace("refs/heads/", "")
|
|
||||||
if branch not in ["master", "main"]:
|
|
||||||
return jsonify({"success": False,
|
|
||||||
"message": "Webhook ignored, as it's not on the master/main branch"})
|
|
||||||
|
|
||||||
elif event == "tag_push":
|
elif event == "tag_push":
|
||||||
ref = json["ref"]
|
ref = json["ref"]
|
||||||
title = ref.replace("refs/tags/", "")
|
title = ref.replace("refs/tags/", "")
|
||||||
else:
|
else:
|
||||||
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
|
return error(400, "Unsupported event. Only 'push' and 'tag_push' are supported.")
|
||||||
.format(event or "null"))
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Perform release
|
# Perform release
|
||||||
#
|
#
|
||||||
|
|
||||||
if package.releases.filter_by(commit_hash=ref).count() > 0:
|
return handleCreateRelease(token, package, title, ref)
|
||||||
return
|
|
||||||
|
|
||||||
return api_create_vcs_release(token, package, title, ref, reason="Webhook")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/gitlab/webhook/", methods=["POST"])
|
|
||||||
@csrf.exempt
|
|
||||||
def webhook():
|
|
||||||
try:
|
|
||||||
return webhook_impl()
|
|
||||||
except KeyError as err:
|
|
||||||
return error(400, "Missing field: {}".format(err.args[0]))
|
|
||||||
|
|
|
@ -1,44 +1,28 @@
|
||||||
from flask import Blueprint, render_template, redirect
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
bp = Blueprint("homepage", __name__)
|
bp = Blueprint("homepage", __name__)
|
||||||
|
|
||||||
from app.models import *
|
from app.models import *
|
||||||
|
import flask_menu as menu
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
|
@menu.register_menu(bp, ".", "Home")
|
||||||
def home():
|
def home():
|
||||||
def join(query):
|
def join(query):
|
||||||
return query.options(
|
return query.options( \
|
||||||
joinedload(Package.license),
|
joinedload(Package.license), \
|
||||||
joinedload(Package.media_license))
|
joinedload(Package.media_license))
|
||||||
|
|
||||||
query = Package.query.filter_by(state=PackageState.APPROVED)
|
query = Package.query.filter_by(approved=True, soft_deleted=False)
|
||||||
count = query.count()
|
count = query.count()
|
||||||
|
new = join(query.order_by(db.desc(Package.created_at))).limit(8).all()
|
||||||
featured = query.filter(Package.tags.any(name="featured")).order_by(func.random()).limit(6).all()
|
|
||||||
|
|
||||||
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
|
|
||||||
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
|
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
|
||||||
pop_gam = join(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all()
|
pop_gam = join(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(4).all()
|
||||||
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
|
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(4).all()
|
||||||
high_reviewed = join(query.order_by(db.desc(Package.score - Package.score_downloads))) \
|
|
||||||
.filter(Package.reviews.any()).limit(4).all()
|
|
||||||
|
|
||||||
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
|
|
||||||
.filter_by(state=PackageState.APPROVED) \
|
|
||||||
.order_by(db.desc(PackageRelease.releaseDate)) \
|
|
||||||
.limit(20).all()
|
|
||||||
updated = updated[:4]
|
|
||||||
|
|
||||||
reviews = PackageReview.query.filter_by(recommends=True).order_by(db.desc(PackageReview.created_at)).limit(5).all()
|
reviews = PackageReview.query.filter_by(recommends=True).order_by(db.desc(PackageReview.created_at)).limit(5).all()
|
||||||
|
|
||||||
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
||||||
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
||||||
|
return render_template("index.html", count=count, downloads=downloads, \
|
||||||
tags = db.session.query(func.count(Tags.c.tag_id), Tag) \
|
new=new, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, reviews=reviews)
|
||||||
.select_from(Tag).outerjoin(Tags).group_by(Tag.id).order_by(db.asc(Tag.title)).all()
|
|
||||||
|
|
||||||
return render_template("index.html", count=count, downloads=downloads, tags=tags, featured=featured,
|
|
||||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed, reviews=reviews)
|
|
||||||
|
|
|
@ -1,35 +1,31 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask import *
|
from flask import *
|
||||||
from sqlalchemy import func
|
|
||||||
from app.models import MetaPackage, Package, db, Dependency, PackageState, ForumTopic
|
|
||||||
|
|
||||||
bp = Blueprint("metapackages", __name__)
|
bp = Blueprint("metapackages", __name__)
|
||||||
|
|
||||||
|
from flask_user import *
|
||||||
|
from app.models import *
|
||||||
|
|
||||||
@bp.route("/metapackages/")
|
@bp.route("/metapackages/")
|
||||||
def list_all():
|
def list_all():
|
||||||
mpackages = db.session.query(MetaPackage, func.count(Package.id)) \
|
mpackages = MetaPackage.query.order_by(db.asc(MetaPackage.name)).all()
|
||||||
.select_from(MetaPackage).outerjoin(MetaPackage.packages) \
|
return render_template("meta/list.html", mpackages=mpackages)
|
||||||
.order_by(db.asc(MetaPackage.name)) \
|
|
||||||
.group_by(MetaPackage.id).all()
|
|
||||||
return render_template("metapackages/list.html", mpackages=mpackages)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/metapackages/<name>/")
|
@bp.route("/metapackages/<name>/")
|
||||||
def view(name):
|
def view(name):
|
||||||
|
@ -37,28 +33,4 @@ def view(name):
|
||||||
if mpackage is None:
|
if mpackage is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
dependers = db.session.query(Package) \
|
return render_template("meta/view.html", mpackage=mpackage)
|
||||||
.select_from(MetaPackage) \
|
|
||||||
.filter(MetaPackage.name==name) \
|
|
||||||
.join(MetaPackage.dependencies) \
|
|
||||||
.join(Dependency.depender) \
|
|
||||||
.filter(Dependency.optional==False, Package.state==PackageState.APPROVED) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
optional_dependers = db.session.query(Package) \
|
|
||||||
.select_from(MetaPackage) \
|
|
||||||
.filter(MetaPackage.name==name) \
|
|
||||||
.join(MetaPackage.dependencies) \
|
|
||||||
.join(Dependency.depender) \
|
|
||||||
.filter(Dependency.optional==True, Package.state==PackageState.APPROVED) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
similar_topics = ForumTopic.query \
|
|
||||||
.filter_by(name=name) \
|
|
||||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
|
||||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
return render_template("metapackages/view.html", mpackage=mpackage,
|
|
||||||
dependers=dependers, optional_dependers=optional_dependers,
|
|
||||||
similar_topics=similar_topics)
|
|
||||||
|
|
|
@ -2,23 +2,22 @@
|
||||||
# Copyright (C) 2020 rubenwardy
|
# Copyright (C) 2020 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import Blueprint, make_response
|
from flask import Blueprint, make_response
|
||||||
|
from app.models import Package, PackageRelease, db, User, UserRank
|
||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
from app.models import Package, db, User, UserRank, PackageState
|
|
||||||
|
|
||||||
bp = Blueprint("metrics", __name__)
|
bp = Blueprint("metrics", __name__)
|
||||||
|
|
||||||
def generate_metrics(full=False):
|
def generate_metrics(full=False):
|
||||||
|
@ -29,37 +28,37 @@ def generate_metrics(full=False):
|
||||||
|
|
||||||
def gen_labels(labels):
|
def gen_labels(labels):
|
||||||
pieces = [key + "=" + str(val) for key, val in labels.items()]
|
pieces = [key + "=" + str(val) for key, val in labels.items()]
|
||||||
return ",".join(pieces)
|
return (",").join(pieces)
|
||||||
|
|
||||||
|
|
||||||
def write_array_stat(name, help, type, data):
|
def write_array_stat(name, help, type, data):
|
||||||
ret = "# HELP {name} {help}\n# TYPE {name} {type}\n" \
|
ret = ("# HELP {name} {help}\n# TYPE {name} {type}\n") \
|
||||||
.format(name=name, help=help, type=type)
|
.format(name=name, help=help, type=type)
|
||||||
|
|
||||||
for entry in data:
|
for entry in data:
|
||||||
assert(len(entry) == 2)
|
assert(len(entry) == 2)
|
||||||
ret += "{name}{{{labels}}} {value}\n" \
|
ret += ("{name}{{{labels}}} {value}\n") \
|
||||||
.format(name=name, labels=gen_labels(entry[0]), value=entry[1])
|
.format(name=name, labels=gen_labels(entry[0]), value=entry[1])
|
||||||
|
|
||||||
return ret + "\n"
|
return ret + "\n"
|
||||||
|
|
||||||
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
|
||||||
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
|
||||||
|
|
||||||
packages = Package.query.filter_by(state=PackageState.APPROVED).count()
|
packages = Package.query.filter_by(approved=True, soft_deleted=False).count()
|
||||||
users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
|
users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
|
||||||
|
|
||||||
ret = ""
|
ret = ""
|
||||||
ret += write_single_stat("contentdb_packages", "Total packages", "gauge", packages)
|
ret += write_single_stat("contentdb_packages", "Total packages", "counter", packages)
|
||||||
ret += write_single_stat("contentdb_users", "Number of registered users", "gauge", users)
|
ret += write_single_stat("contentdb_users", "Number of registered users", "counter", users)
|
||||||
ret += write_single_stat("contentdb_downloads", "Total downloads", "gauge", downloads)
|
ret += write_single_stat("contentdb_downloads", "Total downloads", "counter", downloads)
|
||||||
|
|
||||||
if full:
|
if full:
|
||||||
scores = Package.query.join(User).with_entities(User.username, Package.name, Package.score) \
|
scores = Package.query.join(User).with_entities(User.username, Package.name, Package.score) \
|
||||||
.filter(Package.state==PackageState.APPROVED).all()
|
.filter(Package.approved==True, Package.soft_deleted==False).all()
|
||||||
|
|
||||||
ret += write_array_stat("contentdb_package_score", "Package score", "gauge",
|
ret += write_array_stat("contentdb_package_score", "Package score", "gauge", \
|
||||||
[({ "author": score[0], "name": score[1] }, score[2]) for score in scores])
|
[({ "author": score[0], "name": score[1] }, score[2]) for score in scores])
|
||||||
else:
|
else:
|
||||||
score_result = db.session.query(func.sum(Package.score)).one_or_none()
|
score_result = db.session.query(func.sum(Package.score)).one_or_none()
|
||||||
score = 0 if not score_result or not score_result[0] else score_result[0]
|
score = 0 if not score_result or not score_result[0] else score_result[0]
|
||||||
|
@ -69,6 +68,6 @@ def generate_metrics(full=False):
|
||||||
|
|
||||||
@bp.route("/metrics")
|
@bp.route("/metrics")
|
||||||
def metrics():
|
def metrics():
|
||||||
response = make_response(generate_metrics(), 200)
|
response = make_response(generate_metrics(), 200)
|
||||||
response.mimetype = "text/plain"
|
response.mimetype = "text/plain"
|
||||||
return response
|
return response
|
||||||
|
|
|
@ -1,45 +1,30 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, url_for
|
from flask import Blueprint, render_template, redirect, url_for
|
||||||
from flask_login import current_user, login_required
|
from flask_user import current_user, login_required
|
||||||
from sqlalchemy import or_, desc
|
from app.models import db, Notification
|
||||||
|
|
||||||
from app.models import db, Notification, NotificationType
|
|
||||||
|
|
||||||
bp = Blueprint("notifications", __name__)
|
bp = Blueprint("notifications", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/notifications/")
|
@bp.route("/notifications/")
|
||||||
@login_required
|
@login_required
|
||||||
def list_all():
|
def list_all():
|
||||||
notifications = Notification.query.filter(Notification.user == current_user,
|
return render_template("notifications/list.html")
|
||||||
Notification.type != NotificationType.EDITOR_ALERT, Notification.type != NotificationType.EDITOR_MISC) \
|
|
||||||
.order_by(desc(Notification.created_at)) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
editor_notifications = Notification.query.filter(Notification.user == current_user,
|
|
||||||
or_(Notification.type == NotificationType.EDITOR_ALERT, Notification.type == NotificationType.EDITOR_MISC)) \
|
|
||||||
.order_by(desc(Notification.created_at)) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
return render_template("notifications/list.html",
|
|
||||||
notifications=notifications, editor_notifications=editor_notifications)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/notifications/clear/", methods=["POST"])
|
@bp.route("/notifications/clear/", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
|
|
|
@ -1,68 +1,21 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from flask_babel import gettext
|
|
||||||
|
|
||||||
from app.models import User, Package, Permission
|
|
||||||
|
|
||||||
bp = Blueprint("packages", __name__)
|
bp = Blueprint("packages", __name__)
|
||||||
|
|
||||||
|
from . import packages, screenshots, releases, reviews
|
||||||
def get_package_tabs(user: User, package: Package):
|
|
||||||
if package is None or not package.checkPerm(user, Permission.EDIT_PACKAGE):
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": "edit",
|
|
||||||
"title": gettext("Edit Details"),
|
|
||||||
"url": package.getURL("packages.create_edit")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "releases",
|
|
||||||
"title": gettext("Releases"),
|
|
||||||
"url": package.getURL("packages.list_releases")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "screenshots",
|
|
||||||
"title": gettext("Screenshots"),
|
|
||||||
"url": package.getURL("packages.screenshots")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "maintainers",
|
|
||||||
"title": gettext("Maintainers"),
|
|
||||||
"url": package.getURL("packages.edit_maintainers")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "audit",
|
|
||||||
"title": gettext("Audit Log"),
|
|
||||||
"url": package.getURL("packages.audit")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "share",
|
|
||||||
"title": gettext("Share and Badges"),
|
|
||||||
"url": package.getURL("packages.share")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "remove",
|
|
||||||
"title": gettext("Remove"),
|
|
||||||
"url": package.getURL("packages.remove")
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
from . import packages, screenshots, releases, reviews, game_hub
|
|
||||||
|
|
|
@ -0,0 +1,172 @@
|
||||||
|
# ContentDB
|
||||||
|
# Copyright (C) 2018 rubenwardy
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from flask import *
|
||||||
|
from flask_user import *
|
||||||
|
from app import app
|
||||||
|
from app.models import *
|
||||||
|
|
||||||
|
from app.utils import *
|
||||||
|
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import *
|
||||||
|
from wtforms.validators import *
|
||||||
|
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||||
|
|
||||||
|
from . import PackageForm
|
||||||
|
|
||||||
|
|
||||||
|
class EditRequestForm(PackageForm):
|
||||||
|
edit_title = StringField("Edit Title", [InputRequired(), Length(1, 100)])
|
||||||
|
edit_desc = TextField("Edit Description", [Optional()])
|
||||||
|
|
||||||
|
@app.route("/packages/<author>/<name>/requests/new/", methods=["GET","POST"])
|
||||||
|
@app.route("/packages/<author>/<name>/requests/<id>/edit/", methods=["GET","POST"])
|
||||||
|
@login_required
|
||||||
|
@is_package_page
|
||||||
|
def create_edit_editrequest_page(package, id=None):
|
||||||
|
edited_package = package
|
||||||
|
|
||||||
|
erequest = None
|
||||||
|
if id is not None:
|
||||||
|
erequest = EditRequest.query.get(id)
|
||||||
|
if erequest.package != package:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not erequest.checkPerm(current_user, Permission.EDIT_EDITREQUEST):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if erequest.status != 0:
|
||||||
|
flash("Can't edit EditRequest, it has already been merged or rejected", "danger")
|
||||||
|
return redirect(erequest.getURL())
|
||||||
|
|
||||||
|
edited_package = Package(package)
|
||||||
|
erequest.applyAll(edited_package)
|
||||||
|
|
||||||
|
form = EditRequestForm(request.form, obj=edited_package)
|
||||||
|
if request.method == "GET":
|
||||||
|
deps = edited_package.dependencies
|
||||||
|
form.harddep_str.data = ",".join([str(x) for x in deps if not x.optional])
|
||||||
|
form.softdep_str.data = ",".join([str(x) for x in deps if x.optional])
|
||||||
|
form.provides_str.data = MetaPackage.ListToSpec(edited_package.provides)
|
||||||
|
|
||||||
|
if request.method == "POST" and form.validate():
|
||||||
|
if erequest is None:
|
||||||
|
erequest = EditRequest()
|
||||||
|
erequest.package = package
|
||||||
|
erequest.author = current_user
|
||||||
|
|
||||||
|
erequest.title = form["edit_title"].data
|
||||||
|
erequest.desc = form["edit_desc"].data
|
||||||
|
db.session.add(erequest)
|
||||||
|
|
||||||
|
EditRequestChange.query.filter_by(request=erequest).delete()
|
||||||
|
|
||||||
|
wasChangeMade = False
|
||||||
|
for e in PackagePropertyKey:
|
||||||
|
newValue = form[e.name].data
|
||||||
|
oldValue = getattr(package, e.name)
|
||||||
|
|
||||||
|
newValueComp = newValue
|
||||||
|
oldValueComp = oldValue
|
||||||
|
if type(newValue) is str:
|
||||||
|
newValue = newValue.replace("\r\n", "\n")
|
||||||
|
newValueComp = newValue.strip()
|
||||||
|
oldValueComp = "" if oldValue is None else oldValue.strip()
|
||||||
|
|
||||||
|
if newValueComp != oldValueComp:
|
||||||
|
change = EditRequestChange()
|
||||||
|
change.request = erequest
|
||||||
|
change.key = e
|
||||||
|
change.oldValue = e.convert(oldValue)
|
||||||
|
change.newValue = e.convert(newValue)
|
||||||
|
db.session.add(change)
|
||||||
|
wasChangeMade = True
|
||||||
|
|
||||||
|
if wasChangeMade:
|
||||||
|
msg = "Edit request #{} {}" \
|
||||||
|
.format(erequest.id, "created" if id is None else "edited")
|
||||||
|
addNotification(package.maintainers, current_user, msg, erequest.getURL(), package)
|
||||||
|
addNotification(erequest.author, current_user, msg, erequest.getURL(), package)
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(erequest.getURL())
|
||||||
|
else:
|
||||||
|
flash("No changes detected", "warning")
|
||||||
|
elif erequest is not None:
|
||||||
|
form["edit_title"].data = erequest.title
|
||||||
|
form["edit_desc"].data = erequest.desc
|
||||||
|
|
||||||
|
return render_template("packages/editrequest_create_edit.html", package=package, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/packages/<author>/<name>/requests/<id>/")
|
||||||
|
@is_package_page
|
||||||
|
def view_editrequest_page(package, id):
|
||||||
|
erequest = EditRequest.query.get(id)
|
||||||
|
if erequest is None or erequest.package != package:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return render_template("packages/editrequest_view.html", package=package, request=erequest)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/packages/<author>/<name>/requests/<id>/approve/", methods=["POST"])
|
||||||
|
@is_package_page
|
||||||
|
def approve_editrequest_page(package, id):
|
||||||
|
if not package.checkPerm(current_user, Permission.APPROVE_CHANGES):
|
||||||
|
flash("You don't have permission to do that.", "danger")
|
||||||
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
|
erequest = EditRequest.query.get(id)
|
||||||
|
if erequest is None or erequest.package != package:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if erequest.status != 0:
|
||||||
|
flash("Edit request has already been resolved", "danger")
|
||||||
|
|
||||||
|
else:
|
||||||
|
erequest.status = 1
|
||||||
|
erequest.applyAll(package)
|
||||||
|
|
||||||
|
msg = "Edit request #{} merged".format(erequest.id)
|
||||||
|
addNotification(erequest.author, current_user, msg, erequest.getURL(), package)
|
||||||
|
addNotification(package.maintainers, current_user, msg, erequest.getURL(), package)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
|
@app.route("/packages/<author>/<name>/requests/<id>/reject/", methods=["POST"])
|
||||||
|
@is_package_page
|
||||||
|
def reject_editrequest_page(package, id):
|
||||||
|
if not package.checkPerm(current_user, Permission.APPROVE_CHANGES):
|
||||||
|
flash("You don't have permission to do that.", "danger")
|
||||||
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
|
erequest = EditRequest.query.get(id)
|
||||||
|
if erequest is None or erequest.package != package:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if erequest.status != 0:
|
||||||
|
flash("Edit request has already been resolved", "danger")
|
||||||
|
|
||||||
|
else:
|
||||||
|
erequest.status = 2
|
||||||
|
|
||||||
|
msg = "Edit request #{} rejected".format(erequest.id)
|
||||||
|
addNotification(erequest.author, current_user, msg, erequest.getURL(), package)
|
||||||
|
addNotification(package.maintainers, current_user, msg, erequest.getURL(), package)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return redirect(package.getDetailsURL())
|
|
@ -1,54 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2022 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from flask import render_template, abort
|
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
from . import bp
|
|
||||||
from app.utils import is_package_page
|
|
||||||
from ...models import Package, PackageType, PackageState, db, PackageRelease
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/hub/")
|
|
||||||
@is_package_page
|
|
||||||
def game_hub(package: Package):
|
|
||||||
if package.type != PackageType.GAME:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
def join(query):
|
|
||||||
return query.options(
|
|
||||||
joinedload(Package.license),
|
|
||||||
joinedload(Package.media_license))
|
|
||||||
|
|
||||||
query = Package.query.filter(Package.supported_games.any(game=package), Package.state==PackageState.APPROVED)
|
|
||||||
count = query.count()
|
|
||||||
|
|
||||||
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
|
|
||||||
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
|
|
||||||
pop_gam = join(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all()
|
|
||||||
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
|
|
||||||
high_reviewed = join(query.order_by(db.desc(Package.score - Package.score_downloads))) \
|
|
||||||
.filter(Package.reviews.any()).limit(4).all()
|
|
||||||
|
|
||||||
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
|
|
||||||
.filter(Package.supported_games.any(game=package), Package.state==PackageState.APPROVED) \
|
|
||||||
.order_by(db.desc(PackageRelease.releaseDate)) \
|
|
||||||
.limit(20).all()
|
|
||||||
updated = updated[:4]
|
|
||||||
|
|
||||||
return render_template("packages/game_hub.html", package=package, count=count,
|
|
||||||
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam,
|
|
||||||
high_reviewed=high_reviewed)
|
|
|
@ -1,73 +1,60 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from urllib.parse import quote as urlescape
|
from flask import render_template, abort, request, redirect, url_for, flash
|
||||||
|
from flask_user import current_user
|
||||||
|
import flask_menu as menu
|
||||||
|
|
||||||
|
from . import bp
|
||||||
|
|
||||||
|
from app.models import *
|
||||||
|
from app.querybuilder import QueryBuilder
|
||||||
|
from app.tasks.importtasks import importRepoScreenshot, updateMetaFromRelease
|
||||||
|
from app.utils import *
|
||||||
|
|
||||||
from flask import render_template
|
|
||||||
from flask_babel import lazy_gettext, gettext
|
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from flask_login import login_required
|
from wtforms import *
|
||||||
|
from wtforms.validators import *
|
||||||
|
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
||||||
from sqlalchemy import or_, func
|
from sqlalchemy import or_, func
|
||||||
from sqlalchemy.orm import joinedload, subqueryload
|
from sqlalchemy.orm import joinedload, subqueryload
|
||||||
from wtforms import *
|
|
||||||
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
|
|
||||||
from wtforms.validators import *
|
|
||||||
|
|
||||||
from app.querybuilder import QueryBuilder
|
from celery import uuid
|
||||||
from app.rediscache import has_key, set_key
|
|
||||||
from app.tasks.importtasks import importRepoScreenshot
|
|
||||||
from app.utils import *
|
|
||||||
from . import bp, get_package_tabs
|
|
||||||
from app.logic.LogicError import LogicError
|
|
||||||
from app.logic.packages import do_edit_package
|
|
||||||
from app.models.packages import PackageProvides
|
|
||||||
from app.tasks.webhooktasks import post_discord_webhook
|
|
||||||
|
|
||||||
|
|
||||||
|
@menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
|
||||||
|
@menu.register_menu(bp, ".games", "Games", order=12, endpoint_arguments_constructor=lambda: { 'type': 'game' })
|
||||||
|
@menu.register_menu(bp, ".txp", "Texture Packs", order=13, endpoint_arguments_constructor=lambda: { 'type': 'txp' })
|
||||||
|
@menu.register_menu(bp, ".random", "Random", order=14, endpoint_arguments_constructor=lambda: { 'random': '1', 'lucky': '1' })
|
||||||
@bp.route("/packages/")
|
@bp.route("/packages/")
|
||||||
def list_all():
|
def list_all():
|
||||||
qb = QueryBuilder(request.args)
|
qb = QueryBuilder(request.args)
|
||||||
query = qb.buildPackageQuery()
|
query = qb.buildPackageQuery()
|
||||||
title = qb.title
|
title = qb.title
|
||||||
|
|
||||||
query = query.options(
|
query = query.options( \
|
||||||
joinedload(Package.license),
|
joinedload(Package.license), \
|
||||||
joinedload(Package.media_license),
|
joinedload(Package.media_license), \
|
||||||
subqueryload(Package.tags))
|
subqueryload(Package.tags))
|
||||||
|
|
||||||
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
|
|
||||||
if ip is not None and not is_user_bot():
|
|
||||||
edited = False
|
|
||||||
for tag in qb.tags:
|
|
||||||
edited = True
|
|
||||||
key = "tag/{}/{}".format(ip, tag.name)
|
|
||||||
if not has_key(key):
|
|
||||||
set_key(key, "true")
|
|
||||||
Tag.query.filter_by(id=tag.id).update({
|
|
||||||
"views": Tag.views + 1
|
|
||||||
})
|
|
||||||
|
|
||||||
if edited:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if qb.lucky:
|
if qb.lucky:
|
||||||
package = query.first()
|
package = query.first()
|
||||||
if package:
|
if package:
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
topic = qb.buildTopicQuery().first()
|
topic = qb.buildTopicQuery().first()
|
||||||
if qb.search and topic:
|
if qb.search and topic:
|
||||||
|
@ -93,15 +80,14 @@ def list_all():
|
||||||
qb.show_discarded = True
|
qb.show_discarded = True
|
||||||
topics = qb.buildTopicQuery().all()
|
topics = qb.buildTopicQuery().all()
|
||||||
|
|
||||||
tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \
|
tags = db.session.query(func.count(Tags.c.tag_id), Tag) \
|
||||||
.select_from(Tag).join(Tags).join(Package).group_by(Tag.id).order_by(db.asc(Tag.title))
|
.select_from(Tag).outerjoin(Tags).group_by(Tag.id).order_by(db.asc(Tag.title)).all()
|
||||||
tags = qb.filterPackageQuery(tags_query).all()
|
|
||||||
|
|
||||||
selected_tags = set(qb.tags)
|
selected_tags = set(qb.tags)
|
||||||
|
|
||||||
return render_template("packages/list.html",
|
return render_template("packages/list.html", \
|
||||||
query_hint=title, packages=query.items, pagination=query,
|
title=title, packages=query.items, pagination=query, \
|
||||||
query=search, tags=tags, selected_tags=selected_tags, type=type_name,
|
query=search, tags=tags, selected_tags=selected_tags, type=type_name, \
|
||||||
authors=authors, packages_count=query.total, topics=topics)
|
authors=authors, packages_count=query.total, topics=topics)
|
||||||
|
|
||||||
|
|
||||||
|
@ -115,37 +101,28 @@ def getReleases(package):
|
||||||
@bp.route("/packages/<author>/<name>/")
|
@bp.route("/packages/<author>/<name>/")
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def view(package):
|
def view(package):
|
||||||
show_similar = not package.approved and (
|
alternatives = None
|
||||||
current_user in package.maintainers or
|
if package.type == PackageType.MOD:
|
||||||
package.checkPerm(current_user, Permission.APPROVE_NEW))
|
alternatives = Package.query \
|
||||||
|
.filter_by(name=package.name, type=PackageType.MOD, soft_deleted=False) \
|
||||||
|
.filter(Package.id != package.id) \
|
||||||
|
.order_by(db.desc(Package.score)) \
|
||||||
|
.all()
|
||||||
|
|
||||||
conflicting_modnames = None
|
|
||||||
if show_similar and package.type != PackageType.TXP:
|
|
||||||
conflicting_modnames = db.session.query(MetaPackage.name) \
|
|
||||||
.filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \
|
|
||||||
.filter(MetaPackage.packages.any(Package.id != package.id)) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
conflicting_modnames += db.session.query(ForumTopic.name) \
|
show_similar_topics = current_user == package.author or \
|
||||||
.filter(ForumTopic.name.in_([ mp.name for mp in package.provides ])) \
|
package.checkPerm(current_user, Permission.APPROVE_NEW)
|
||||||
|
|
||||||
|
similar_topics = None if not show_similar_topics else \
|
||||||
|
ForumTopic.query \
|
||||||
|
.filter_by(name=package.name) \
|
||||||
.filter(ForumTopic.topic_id != package.forums) \
|
.filter(ForumTopic.topic_id != package.forums) \
|
||||||
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
|
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
|
||||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
conflicting_modnames = set([x[0] for x in conflicting_modnames])
|
|
||||||
|
|
||||||
packages_uses = None
|
|
||||||
if package.type == PackageType.MOD:
|
|
||||||
packages_uses = Package.query.filter(
|
|
||||||
Package.type == PackageType.MOD,
|
|
||||||
Package.id != package.id,
|
|
||||||
Package.state == PackageState.APPROVED,
|
|
||||||
Package.dependencies.any(
|
|
||||||
Dependency.meta_package_id.in_([p.id for p in package.provides]))) \
|
|
||||||
.order_by(db.desc(Package.score)).limit(6).all()
|
|
||||||
|
|
||||||
releases = getReleases(package)
|
releases = getReleases(package)
|
||||||
|
requests = [r for r in package.requests if r.status == 0]
|
||||||
|
|
||||||
review_thread = package.review_thread
|
review_thread = package.review_thread
|
||||||
if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
|
if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
|
||||||
|
@ -153,19 +130,22 @@ def view(package):
|
||||||
|
|
||||||
topic_error = None
|
topic_error = None
|
||||||
topic_error_lvl = "warning"
|
topic_error_lvl = "warning"
|
||||||
if package.state != PackageState.APPROVED and package.forums is not None:
|
if not package.approved and package.forums is not None:
|
||||||
errors = []
|
errors = []
|
||||||
if Package.query.filter(Package.forums==package.forums, Package.state!=PackageState.DELETED).count() > 1:
|
if Package.query.filter_by(forums=package.forums, soft_deleted=False).count() > 1:
|
||||||
errors.append("<b>" + gettext("Error: Another package already uses this forum topic!") + "</b>")
|
errors.append("<b>Error: Another package already uses this forum topic!</b>")
|
||||||
topic_error_lvl = "danger"
|
topic_error_lvl = "danger"
|
||||||
|
|
||||||
topic = ForumTopic.query.get(package.forums)
|
topic = ForumTopic.query.get(package.forums)
|
||||||
if topic is not None:
|
if topic is not None:
|
||||||
if topic.author != package.author:
|
if topic.author != package.author:
|
||||||
errors.append("<b>" + gettext("Error: Forum topic author doesn't match package author.") + "</b>")
|
errors.append("<b>Error: Forum topic author doesn't match package author.</b>")
|
||||||
topic_error_lvl = "danger"
|
topic_error_lvl = "danger"
|
||||||
|
|
||||||
|
if topic.wip:
|
||||||
|
errors.append("Warning: Forum topic is in WIP section, make sure package meets playability standards.")
|
||||||
elif package.type != PackageType.TXP:
|
elif package.type != PackageType.TXP:
|
||||||
errors.append(gettext("Warning: Forum topic not found. This may happen if the topic has only just been created."))
|
errors.append("Warning: Forum topic not found. This may happen if the topic has only just been created.")
|
||||||
|
|
||||||
topic_error = "<br />".join(errors)
|
topic_error = "<br />".join(errors)
|
||||||
|
|
||||||
|
@ -173,35 +153,18 @@ def view(package):
|
||||||
threads = Thread.query.filter_by(package_id=package.id, review_id=None)
|
threads = Thread.query.filter_by(package_id=package.id, review_id=None)
|
||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
threads = threads.filter_by(private=False)
|
threads = threads.filter_by(private=False)
|
||||||
elif not current_user.rank.atLeast(UserRank.APPROVER) and not current_user == package.author:
|
elif not current_user.rank.atLeast(UserRank.EDITOR) and not current_user == package.author:
|
||||||
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
|
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
|
||||||
|
|
||||||
has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).count() > 0
|
has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).count() > 0
|
||||||
|
|
||||||
return render_template("packages/view.html",
|
return render_template("packages/view.html", \
|
||||||
package=package, releases=releases, packages_uses=packages_uses,
|
package=package, releases=releases, requests=requests, \
|
||||||
conflicting_modnames=conflicting_modnames,
|
alternatives=alternatives, similar_topics=similar_topics, \
|
||||||
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
|
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl, \
|
||||||
threads=threads.all(), has_review=has_review)
|
threads=threads.all(), has_review=has_review)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/shields/<type>/")
|
|
||||||
@is_package_page
|
|
||||||
def shield(package, type):
|
|
||||||
if type == "title":
|
|
||||||
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \
|
|
||||||
.format(urlescape(package.title), urlescape("#375a7f"))
|
|
||||||
elif type == "downloads":
|
|
||||||
#api_url = abs_url_for("api.package", author=package.author.username, name=package.name)
|
|
||||||
api_url = "https://content.minetest.net" + url_for("api.package", author=package.author.username, name=package.name)
|
|
||||||
url = "https://img.shields.io/badge/dynamic/json?color={}&label=ContentDB&query=downloads&suffix=+downloads&url={}" \
|
|
||||||
.format(urlescape("#375a7f"), urlescape(api_url))
|
|
||||||
else:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
return redirect(url)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/download/")
|
@bp.route("/packages/<author>/<name>/download/")
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def download(package):
|
def download(package):
|
||||||
|
@ -212,48 +175,36 @@ def download(package):
|
||||||
not "text/html" in request.accept_mimetypes:
|
not "text/html" in request.accept_mimetypes:
|
||||||
return "", 204
|
return "", 204
|
||||||
else:
|
else:
|
||||||
flash(gettext("No download available."), "danger")
|
flash("No download available.", "danger")
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
else:
|
else:
|
||||||
return redirect(release.getDownloadURL())
|
return redirect(release.getDownloadURL(), code=302)
|
||||||
|
|
||||||
|
|
||||||
def makeLabel(obj):
|
|
||||||
if obj.description:
|
|
||||||
return "{}: {}".format(obj.title, obj.description)
|
|
||||||
else:
|
|
||||||
return obj.title
|
|
||||||
|
|
||||||
|
|
||||||
class PackageForm(FlaskForm):
|
class PackageForm(FlaskForm):
|
||||||
type = SelectField(lazy_gettext("Type"), [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
|
name = StringField("Name (Technical)", [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||||
title = StringField(lazy_gettext("Title (Human-readable)"), [InputRequired(), Length(1, 100)])
|
title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 100)])
|
||||||
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
|
short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
|
||||||
short_desc = StringField(lazy_gettext("Short Description (Plaintext)"), [InputRequired(), Length(1,200)])
|
desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
|
||||||
|
type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
|
||||||
dev_state = SelectField(lazy_gettext("Maintenance State"), [InputRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
|
license = QuerySelectField("License", [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||||
|
media_license = QuerySelectField("Media License", [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||||
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel)
|
provides_str = StringField("Provides (mods included in package)", [Optional()])
|
||||||
content_warnings = QuerySelectMultipleField(lazy_gettext('Content Warnings'), query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel)
|
tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||||
license = QuerySelectField(lazy_gettext("License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
harddep_str = StringField("Hard Dependencies", [Optional()])
|
||||||
media_license = QuerySelectField(lazy_gettext("Media License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
softdep_str = StringField("Soft Dependencies", [Optional()])
|
||||||
|
repo = StringField("VCS Repository URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||||
desc = TextAreaField(lazy_gettext("Long Description (Markdown)"), [Optional(), Length(0,10000)])
|
website = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||||
|
issueTracker = StringField("Issue Tracker URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||||
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
forums = IntegerField("Forum Topic ID", [Optional(), NumberRange(0,999999)])
|
||||||
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
submit = SubmitField("Save")
|
||||||
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
|
||||||
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)])
|
|
||||||
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
|
||||||
|
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/new/", methods=["GET", "POST"])
|
@bp.route("/packages/new/", methods=["GET", "POST"])
|
||||||
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def create_edit(author=None, name=None):
|
def create_edit(author=None, name=None):
|
||||||
package = None
|
package = None
|
||||||
|
form = None
|
||||||
if author is None:
|
if author is None:
|
||||||
form = PackageForm(formdata=request.form)
|
form = PackageForm(formdata=request.form)
|
||||||
author = request.args.get("author")
|
author = request.args.get("author")
|
||||||
|
@ -262,19 +213,17 @@ def create_edit(author=None, name=None):
|
||||||
else:
|
else:
|
||||||
author = User.query.filter_by(username=author).first()
|
author = User.query.filter_by(username=author).first()
|
||||||
if author is None:
|
if author is None:
|
||||||
flash(gettext("Unable to find that user"), "danger")
|
flash("Unable to find that user", "danger")
|
||||||
return redirect(url_for("packages.create_edit"))
|
return redirect(url_for("packages.create_edit"))
|
||||||
|
|
||||||
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
|
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
|
||||||
flash(gettext("Permission denied"), "danger")
|
flash("Permission denied", "danger")
|
||||||
return redirect(url_for("packages.create_edit"))
|
return redirect(url_for("packages.create_edit"))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
package = getPackageByInfo(author, name)
|
package = getPackageByInfo(author, name)
|
||||||
if package is None:
|
|
||||||
abort(404)
|
|
||||||
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
|
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
author = package.author
|
author = package.author
|
||||||
|
|
||||||
|
@ -283,28 +232,27 @@ def create_edit(author=None, name=None):
|
||||||
# Initial form class from post data and default data
|
# Initial form class from post data and default data
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
if package is None:
|
if package is None:
|
||||||
form.name.data = request.args.get("bname")
|
form.name.data = request.args.get("bname")
|
||||||
form.title.data = request.args.get("title")
|
form.title.data = request.args.get("title")
|
||||||
form.repo.data = request.args.get("repo")
|
form.repo.data = request.args.get("repo")
|
||||||
form.forums.data = request.args.get("forums")
|
form.forums.data = request.args.get("forums")
|
||||||
form.license.data = None
|
form.license.data = None
|
||||||
form.media_license.data = None
|
form.media_license.data = None
|
||||||
else:
|
else:
|
||||||
form.tags.data = package.tags
|
form.harddep_str.data = ",".join([str(x) for x in package.getSortedHardDependencies() ])
|
||||||
form.content_warnings.data = package.content_warnings
|
form.softdep_str.data = ",".join([str(x) for x in package.getSortedOptionalDependencies() ])
|
||||||
|
form.provides_str.data = MetaPackage.ListToSpec(package.provides)
|
||||||
|
form.tags.data = list(package.tags)
|
||||||
|
|
||||||
if request.method == "POST" and form.type.data == PackageType.TXP:
|
if request.method == "POST" and form.validate():
|
||||||
form.license.data = form.media_license.data
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
wasNew = False
|
wasNew = False
|
||||||
if not package:
|
if not package:
|
||||||
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
|
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
|
||||||
if package is not None:
|
if package is not None:
|
||||||
if package.state == PackageState.READY_FOR_REVIEW:
|
if package.soft_deleted:
|
||||||
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
|
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
|
||||||
else:
|
else:
|
||||||
flash(gettext("Package already exists!"), "danger")
|
flash("Package already exists!", "danger")
|
||||||
return redirect(url_for("packages.create_edit"))
|
return redirect(url_for("packages.create_edit"))
|
||||||
|
|
||||||
package = Package()
|
package = Package()
|
||||||
|
@ -312,94 +260,96 @@ def create_edit(author=None, name=None):
|
||||||
package.maintainers.append(author)
|
package.maintainers.append(author)
|
||||||
wasNew = True
|
wasNew = True
|
||||||
|
|
||||||
try:
|
elif package.approved and package.name != form.name.data and \
|
||||||
do_edit_package(current_user, package, wasNew, True, {
|
not package.checkPerm(current_user, Permission.CHANGE_NAME):
|
||||||
"type": form.type.data,
|
flash("Unable to change package name", "danger")
|
||||||
"title": form.title.data,
|
return redirect(url_for("packages.create_edit", author=author, name=name))
|
||||||
"name": form.name.data,
|
|
||||||
"short_desc": form.short_desc.data,
|
|
||||||
"dev_state": form.dev_state.data,
|
|
||||||
"tags": form.tags.raw_data,
|
|
||||||
"content_warnings": form.content_warnings.raw_data,
|
|
||||||
"license": form.license.data,
|
|
||||||
"media_license": form.media_license.data,
|
|
||||||
"desc": form.desc.data,
|
|
||||||
"repo": form.repo.data,
|
|
||||||
"website": form.website.data,
|
|
||||||
"issueTracker": form.issueTracker.data,
|
|
||||||
"forums": form.forums.data,
|
|
||||||
"video_url": form.video_url.data,
|
|
||||||
})
|
|
||||||
|
|
||||||
if wasNew and package.repo is not None:
|
else:
|
||||||
importRepoScreenshot.delay(package.id)
|
msg = "Edited {}".format(package.title)
|
||||||
|
|
||||||
next_url = package.getURL("packages.view")
|
addNotification(package.maintainers, current_user,
|
||||||
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
|
msg, package.getDetailsURL(), package)
|
||||||
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
|
|
||||||
elif wasNew:
|
|
||||||
next_url = package.getURL("packages.setup_releases")
|
|
||||||
|
|
||||||
return redirect(next_url)
|
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
|
||||||
except LogicError as e:
|
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
|
||||||
flash(e.message, "danger")
|
|
||||||
|
|
||||||
package_query = Package.query.filter_by(state=PackageState.APPROVED)
|
form.populate_obj(package) # copy to row
|
||||||
|
|
||||||
|
if package.type== PackageType.TXP:
|
||||||
|
package.license = package.media_license
|
||||||
|
|
||||||
|
mpackage_cache = {}
|
||||||
|
package.provides.clear()
|
||||||
|
mpackages = MetaPackage.SpecToList(form.provides_str.data, mpackage_cache)
|
||||||
|
for m in mpackages:
|
||||||
|
package.provides.append(m)
|
||||||
|
|
||||||
|
Dependency.query.filter_by(depender=package).delete()
|
||||||
|
deps = Dependency.SpecToList(package, form.harddep_str.data, mpackage_cache)
|
||||||
|
for dep in deps:
|
||||||
|
dep.optional = False
|
||||||
|
db.session.add(dep)
|
||||||
|
|
||||||
|
deps = Dependency.SpecToList(package, form.softdep_str.data, mpackage_cache)
|
||||||
|
for dep in deps:
|
||||||
|
dep.optional = True
|
||||||
|
db.session.add(dep)
|
||||||
|
|
||||||
|
if wasNew and package.type == PackageType.MOD and not package.name in mpackage_cache:
|
||||||
|
m = MetaPackage.GetOrCreate(package.name, mpackage_cache)
|
||||||
|
package.provides.append(m)
|
||||||
|
|
||||||
|
package.tags.clear()
|
||||||
|
for tag in form.tags.raw_data:
|
||||||
|
package.tags.append(Tag.query.get(tag))
|
||||||
|
|
||||||
|
db.session.commit() # save
|
||||||
|
|
||||||
|
next_url = package.getDetailsURL()
|
||||||
|
if wasNew and package.repo is not None:
|
||||||
|
task = importRepoScreenshot.delay(package.id)
|
||||||
|
next_url = url_for("tasks.check", id=task.id, r=next_url)
|
||||||
|
|
||||||
|
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
|
||||||
|
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
|
||||||
|
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
package_query = Package.query.filter_by(approved=True, soft_deleted=False)
|
||||||
if package is not None:
|
if package is not None:
|
||||||
package_query = package_query.filter(Package.id != package.id)
|
package_query = package_query.filter(Package.id != package.id)
|
||||||
|
|
||||||
enableWizard = name is None and request.method != "POST"
|
enableWizard = name is None and request.method != "POST"
|
||||||
return render_template("packages/create_edit.html", package=package,
|
return render_template("packages/create_edit.html", package=package, \
|
||||||
form=form, author=author, enable_wizard=enableWizard,
|
form=form, author=author, enable_wizard=enableWizard, \
|
||||||
packages=package_query.all(),
|
packages=package_query.all(), \
|
||||||
mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all(),
|
mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all())
|
||||||
tabs=get_package_tabs(current_user, package), current_tab="edit")
|
|
||||||
|
|
||||||
|
@bp.route("/packages/<author>/<name>/approve/", methods=["POST"])
|
||||||
@bp.route("/packages/<author>/<name>/state/", methods=["POST"])
|
|
||||||
@login_required
|
@login_required
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def move_to_state(package):
|
def approve(package):
|
||||||
state = PackageState.get(request.args.get("state"))
|
if not package.checkPerm(current_user, Permission.APPROVE_NEW):
|
||||||
if state is None:
|
flash("You don't have permission to do that.", "danger")
|
||||||
abort(400)
|
|
||||||
|
|
||||||
if not package.canMoveToState(current_user, state):
|
elif package.approved:
|
||||||
flash(gettext("You don't have permission to do that"), "danger")
|
flash("Package has already been approved", "danger")
|
||||||
return redirect(package.getURL("packages.view"))
|
|
||||||
|
|
||||||
package.state = state
|
else:
|
||||||
msg = "Marked {} as {}".format(package.title, state.value)
|
package.approved = True
|
||||||
|
|
||||||
if state == PackageState.APPROVED:
|
|
||||||
if not package.approved_at:
|
|
||||||
post_discord_webhook.delay(package.author.username,
|
|
||||||
"New package {}".format(package.getURL("packages.view", absolute=True)), False)
|
|
||||||
package.approved_at = datetime.datetime.now()
|
|
||||||
|
|
||||||
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
|
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
|
||||||
for s in screenshots:
|
for s in screenshots:
|
||||||
s.approved = True
|
s.approved = True
|
||||||
|
|
||||||
msg = "Approved {}".format(package.title)
|
msg = "Approved {}".format(package.title)
|
||||||
elif state == PackageState.READY_FOR_REVIEW:
|
addNotification(package.maintainers, current_user, msg, package.getDetailsURL(), package)
|
||||||
post_discord_webhook.delay(package.author.username,
|
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.EDITOR
|
||||||
"Ready for Review: {}".format(package.getURL("packages.view", absolute=True)), True)
|
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
|
return redirect(package.getDetailsURL())
|
||||||
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
|
|
||||||
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if package.state == PackageState.CHANGES_NEEDED:
|
|
||||||
flash(gettext("Please comment what changes are needed in the approval thread"), "warning")
|
|
||||||
if package.review_thread:
|
|
||||||
return redirect(package.review_thread.getViewURL())
|
|
||||||
else:
|
|
||||||
return redirect(url_for('threads.new', pid=package.id, title='Package approval comments'))
|
|
||||||
|
|
||||||
return redirect(package.getURL("packages.view"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
|
||||||
|
@ -407,51 +357,48 @@ def move_to_state(package):
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def remove(package):
|
def remove(package):
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
return render_template("packages/remove.html", package=package,
|
return render_template("packages/remove.html", package=package)
|
||||||
tabs=get_package_tabs(current_user, package), current_tab="remove")
|
|
||||||
|
|
||||||
reason = request.form.get("reason") or "?"
|
|
||||||
|
|
||||||
if "delete" in request.form:
|
if "delete" in request.form:
|
||||||
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
|
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
|
||||||
flash(gettext("You don't have permission to do that."), "danger")
|
flash("You don't have permission to do that.", "danger")
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
package.state = PackageState.DELETED
|
package.soft_deleted = True
|
||||||
|
|
||||||
url = url_for("users.profile", username=package.author.username)
|
url = url_for("users.profile", username=package.author.username)
|
||||||
msg = "Deleted {}, reason={}".format(package.title, reason)
|
msg = "Deleted {}".format(package.title)
|
||||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
|
addNotification(package.maintainers, current_user, msg, url, package)
|
||||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
|
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash(gettext("Deleted package"), "success")
|
flash("Deleted package", "success")
|
||||||
|
|
||||||
return redirect(url)
|
return redirect(url)
|
||||||
elif "unapprove" in request.form:
|
elif "unapprove" in request.form:
|
||||||
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
|
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
|
||||||
flash(gettext("You don't have permission to do that."), "danger")
|
flash("You don't have permission to do that.", "danger")
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
package.state = PackageState.WIP
|
package.approved = False
|
||||||
|
|
||||||
msg = "Unapproved {}, reason={}".format(package.title, reason)
|
msg = "Unapproved {}".format(package.title)
|
||||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
|
addNotification(package.maintainers, current_user, msg, package.getDetailsURL(), package)
|
||||||
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getURL("packages.view"), package)
|
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getDetailsURL(), package)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash(gettext("Unapproved package"), "success")
|
flash("Unapproved package", "success")
|
||||||
|
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
else:
|
else:
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PackageMaintainersForm(FlaskForm):
|
class PackageMaintainersForm(FlaskForm):
|
||||||
maintainers_str = StringField(lazy_gettext("Maintainers (Comma-separated)"), [Optional()])
|
maintainers_str = StringField("Maintainers (Comma-separated)", [Optional()])
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/edit-maintainers/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/edit-maintainers/", methods=["GET", "POST"])
|
||||||
|
@ -459,30 +406,26 @@ class PackageMaintainersForm(FlaskForm):
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def edit_maintainers(package):
|
def edit_maintainers(package):
|
||||||
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
|
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
|
||||||
flash(gettext("You do not have permission to edit maintainers"), "danger")
|
flash("You do not have permission to edit maintainers", "danger")
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
form = PackageMaintainersForm(formdata=request.form)
|
form = PackageMaintainersForm(formdata=request.form)
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
form.maintainers_str.data = ", ".join([ x.username for x in package.maintainers if x != package.author ])
|
form.maintainers_str.data = ", ".join([ x.username for x in package.maintainers if x != package.author ])
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if request.method == "POST" and form.validate():
|
||||||
usernames = [x.strip().lower() for x in form.maintainers_str.data.split(",")]
|
usernames = [x.strip().lower() for x in form.maintainers_str.data.split(",")]
|
||||||
users = User.query.filter(func.lower(User.username).in_(usernames)).all()
|
users = User.query.filter(func.lower(User.username).in_(usernames)).all()
|
||||||
|
|
||||||
thread = package.threads.filter_by(author=get_system_user()).first()
|
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
if not user in package.maintainers:
|
if not user in package.maintainers:
|
||||||
if thread:
|
addNotification(user, current_user,
|
||||||
thread.watchers.append(user)
|
"Added you as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
|
||||||
addNotification(user, current_user, NotificationType.MAINTAINER,
|
|
||||||
"Added you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
|
||||||
|
|
||||||
for user in package.maintainers:
|
for user in package.maintainers:
|
||||||
if user != package.author and not user in users:
|
if user != package.author and not user in users:
|
||||||
addNotification(user, current_user, NotificationType.MAINTAINER,
|
addNotification(user, current_user,
|
||||||
"Removed you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
"Removed you as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
|
||||||
|
|
||||||
package.maintainers.clear()
|
package.maintainers.clear()
|
||||||
package.maintainers.extend(users)
|
package.maintainers.extend(users)
|
||||||
|
@ -490,18 +433,18 @@ def edit_maintainers(package):
|
||||||
package.maintainers.append(package.author)
|
package.maintainers.append(package.author)
|
||||||
|
|
||||||
msg = "Edited {} maintainers".format(package.title)
|
msg = "Edited {} maintainers".format(package.title)
|
||||||
addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getURL("packages.view"), package)
|
addNotification(package.author, current_user, msg, package.getDetailsURL(), package)
|
||||||
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
|
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
|
||||||
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
|
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).order_by(db.asc(User.username)).all()
|
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).order_by(db.asc(User.username)).all()
|
||||||
|
|
||||||
return render_template("packages/edit_maintainers.html", package=package, form=form,
|
return render_template("packages/edit_maintainers.html", \
|
||||||
users=users, tabs=get_package_tabs(current_user, package), current_tab="maintainers")
|
package=package, form=form, users=users)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/remove-self-maintainer/", methods=["POST"])
|
@bp.route("/packages/<author>/<name>/remove-self-maintainer/", methods=["POST"])
|
||||||
|
@ -509,105 +452,45 @@ def edit_maintainers(package):
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def remove_self_maintainers(package):
|
def remove_self_maintainers(package):
|
||||||
if not current_user in package.maintainers:
|
if not current_user in package.maintainers:
|
||||||
flash(gettext("You are not a maintainer"), "danger")
|
flash("You are not a maintainer", "danger")
|
||||||
|
|
||||||
elif current_user == package.author:
|
elif current_user == package.author:
|
||||||
flash(gettext("Package owners cannot remove themselves as maintainers"), "danger")
|
flash("Package owners cannot remove themselves as maintainers", "danger")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
package.maintainers.remove(current_user)
|
package.maintainers.remove(current_user)
|
||||||
|
|
||||||
addNotification(package.author, current_user, NotificationType.MAINTAINER,
|
addNotification(package.author, current_user,
|
||||||
"Removed themself as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
|
"Removed themself as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/audit/")
|
@bp.route("/packages/<author>/<name>/import-meta/", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def audit(package):
|
def update_from_release(package):
|
||||||
if not (package.checkPerm(current_user, Permission.EDIT_PACKAGE) or
|
if not package.checkPerm(current_user, Permission.REIMPORT_META):
|
||||||
package.checkPerm(current_user, Permission.APPROVE_NEW)):
|
flash("You don't have permission to reimport meta", "danger")
|
||||||
abort(403)
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
page = get_int_or_abort(request.args.get("page"), 1)
|
release = package.releases.first()
|
||||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
if not release:
|
||||||
|
flash("Release needed", "danger")
|
||||||
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
query = package.audit_log_entries.order_by(db.desc(AuditLogEntry.created_at))
|
msg = "Updated meta from latest release"
|
||||||
|
addNotification(package.maintainers, current_user,
|
||||||
|
msg, package.getDetailsURL(), package)
|
||||||
|
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
|
||||||
|
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
|
||||||
|
|
||||||
pagination = query.paginate(page, num, True)
|
db.session.commit()
|
||||||
return render_template("packages/audit.html", log=pagination.items, pagination=pagination,
|
|
||||||
package=package, tabs=get_package_tabs(current_user, package), current_tab="audit")
|
|
||||||
|
|
||||||
|
task_id = uuid()
|
||||||
|
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
|
||||||
|
updateMetaFromRelease.apply_async((release.id, zippath), task_id=task_id)
|
||||||
|
|
||||||
class PackageAliasForm(FlaskForm):
|
return redirect(url_for("tasks.check", id=task_id, r=package.getEditURL()))
|
||||||
author = StringField(lazy_gettext("Author Name"), [InputRequired(), Length(1, 50)])
|
|
||||||
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100),
|
|
||||||
Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
|
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/aliases/")
|
|
||||||
@rank_required(UserRank.EDITOR)
|
|
||||||
@is_package_page
|
|
||||||
def alias_list(package: Package):
|
|
||||||
return render_template("packages/alias_list.html", package=package)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/aliases/new/", methods=["GET", "POST"])
|
|
||||||
@bp.route("/packages/<author>/<name>/aliases/<int:alias_id>/", methods=["GET", "POST"])
|
|
||||||
@rank_required(UserRank.EDITOR)
|
|
||||||
@is_package_page
|
|
||||||
def alias_create_edit(package: Package, alias_id: int = None):
|
|
||||||
alias = None
|
|
||||||
if alias_id:
|
|
||||||
alias = PackageAlias.query.get(alias_id)
|
|
||||||
if alias is None or alias.package != package:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
form = PackageAliasForm(request.form, obj=alias)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
if alias is None:
|
|
||||||
alias = PackageAlias()
|
|
||||||
alias.package = package
|
|
||||||
db.session.add(alias)
|
|
||||||
|
|
||||||
form.populate_obj(alias)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(package.getURL("packages.alias_list"))
|
|
||||||
|
|
||||||
return render_template("packages/alias_create_edit.html", package=package, form=form)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/share/")
|
|
||||||
@login_required
|
|
||||||
@is_package_page
|
|
||||||
def share(package):
|
|
||||||
return render_template("packages/share.html", package=package,
|
|
||||||
tabs=get_package_tabs(current_user, package), current_tab="share")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/similar/")
|
|
||||||
@is_package_page
|
|
||||||
def similar(package):
|
|
||||||
packages_modnames = {}
|
|
||||||
for metapackage in package.provides:
|
|
||||||
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,
|
|
||||||
Package.state != PackageState.DELETED) \
|
|
||||||
.filter(Package.provides.any(PackageProvides.c.metapackage_id == metapackage.id)) \
|
|
||||||
.order_by(db.desc(Package.score)) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
similar_topics = ForumTopic.query \
|
|
||||||
.filter_by(name=package.name) \
|
|
||||||
.filter(ForumTopic.topic_id != package.forums) \
|
|
||||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
|
||||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
return render_template("packages/similar.html", package=package,
|
|
||||||
packages_modnames=packages_modnames, similar_topics=similar_topics)
|
|
||||||
|
|
|
@ -1,41 +1,35 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask import *
|
from flask import *
|
||||||
from flask_babel import gettext, lazy_gettext
|
from flask_user import *
|
||||||
from flask_login import login_required
|
|
||||||
|
from . import bp
|
||||||
|
|
||||||
|
from app.rediscache import has_key, set_key, make_download_key
|
||||||
|
from app.models import *
|
||||||
|
from app.tasks.importtasks import makeVCSRelease, checkZipRelease, updateMetaFromRelease
|
||||||
|
from app.utils import *
|
||||||
|
|
||||||
|
from celery import uuid
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import *
|
from wtforms import *
|
||||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
|
||||||
from wtforms.validators import *
|
from wtforms.validators import *
|
||||||
|
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||||
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
|
|
||||||
from app.rediscache import has_key, set_key, make_download_key
|
|
||||||
from app.tasks.importtasks import check_update_config
|
|
||||||
from app.utils import *
|
|
||||||
from . import bp, get_package_tabs
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
|
|
||||||
@is_package_page
|
|
||||||
def list_releases(package):
|
|
||||||
return render_template("packages/releases_list.html",
|
|
||||||
package=package,
|
|
||||||
tabs=get_package_tabs(current_user, package), current_tab="releases")
|
|
||||||
|
|
||||||
|
|
||||||
def get_mt_releases(is_max):
|
def get_mt_releases(is_max):
|
||||||
|
@ -49,58 +43,81 @@ def get_mt_releases(is_max):
|
||||||
|
|
||||||
|
|
||||||
class CreatePackageReleaseForm(FlaskForm):
|
class CreatePackageReleaseForm(FlaskForm):
|
||||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
title = StringField("Title", [InputRequired(), Length(1, 30)])
|
||||||
uploadOpt = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload")
|
uploadOpt = RadioField ("Method", choices=[("upload", "File Upload")], default="upload")
|
||||||
vcsLabel = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
|
vcsLabel = StringField("VCS Commit Hash, Branch, or Tag", default="master")
|
||||||
fileUpload = FileField(lazy_gettext("File Upload"))
|
fileUpload = FileField("File Upload")
|
||||||
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
|
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
|
||||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||||
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
|
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
|
||||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
class EditPackageReleaseForm(FlaskForm):
|
class EditPackageReleaseForm(FlaskForm):
|
||||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
|
title = StringField("Title", [InputRequired(), Length(1, 30)])
|
||||||
url = StringField(lazy_gettext("URL"), [Optional()])
|
url = StringField("URL", [URL])
|
||||||
task_id = StringField(lazy_gettext("Task ID"), filters = [lambda x: x or None])
|
task_id = StringField("Task ID", filters = [lambda x: x or None])
|
||||||
approved = BooleanField(lazy_gettext("Is Approved"))
|
approved = BooleanField("Is Approved")
|
||||||
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
|
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
|
||||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||||
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
|
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
|
||||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def create_release(package):
|
def create_release(package):
|
||||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
# Initial form class from post data and default data
|
# Initial form class from post data and default data
|
||||||
form = CreatePackageReleaseForm()
|
form = CreatePackageReleaseForm()
|
||||||
if package.repo is not None:
|
if package.repo is not None:
|
||||||
form["uploadOpt"].choices = [("vcs", gettext("Import from Git")), ("upload", gettext("Upload .zip file"))]
|
form["uploadOpt"].choices = [("vcs", "From Git Commit or Branch"), ("upload", "File Upload")]
|
||||||
if request.method == "GET":
|
if request.method != "POST":
|
||||||
form["uploadOpt"].data = "vcs"
|
form["uploadOpt"].data = "vcs"
|
||||||
form.vcsLabel.data = request.args.get("ref")
|
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "POST" and form.validate():
|
||||||
form.title.data = request.args.get("title")
|
if form["uploadOpt"].data == "vcs":
|
||||||
|
rel = PackageRelease()
|
||||||
|
rel.package = package
|
||||||
|
rel.title = form["title"].data
|
||||||
|
rel.url = ""
|
||||||
|
rel.task_id = uuid()
|
||||||
|
rel.min_rel = form["min_rel"].data.getActual()
|
||||||
|
rel.max_rel = form["max_rel"].data.getActual()
|
||||||
|
db.session.add(rel)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
makeVCSRelease.apply_async((rel.id, form["vcsLabel"].data), task_id=rel.task_id)
|
||||||
|
|
||||||
|
msg = "Release {} created".format(rel.title)
|
||||||
|
addNotification(package.maintainers, current_user, msg, rel.getEditURL(), package)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
try:
|
|
||||||
if form["uploadOpt"].data == "vcs":
|
|
||||||
rel = do_create_vcs_release(current_user, package, form.title.data,
|
|
||||||
form.vcsLabel.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
|
|
||||||
else:
|
|
||||||
rel = do_create_zip_release(current_user, package, form.title.data,
|
|
||||||
form.fileUpload.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
|
|
||||||
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
|
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
|
||||||
except LogicError as e:
|
else:
|
||||||
flash(e.message, "danger")
|
uploadedUrl, uploadedPath = doFileUpload(form.fileUpload.data, "zip", "a zip file")
|
||||||
|
if uploadedUrl is not None:
|
||||||
|
rel = PackageRelease()
|
||||||
|
rel.package = package
|
||||||
|
rel.title = form["title"].data
|
||||||
|
rel.url = uploadedUrl
|
||||||
|
rel.task_id = uuid()
|
||||||
|
rel.min_rel = form["min_rel"].data.getActual()
|
||||||
|
rel.max_rel = form["max_rel"].data.getActual()
|
||||||
|
db.session.add(rel)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
checkZipRelease.apply_async((rel.id, uploadedPath), task_id=rel.task_id)
|
||||||
|
updateMetaFromRelease.delay(rel.id, uploadedPath)
|
||||||
|
|
||||||
|
msg = "Release {} created".format(rel.title)
|
||||||
|
addNotification(package.maintainers, current_user, msg, rel.getEditURL(), package)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
|
||||||
|
|
||||||
return render_template("packages/release_new.html", package=package, form=form)
|
return render_template("packages/release_new.html", package=package, form=form)
|
||||||
|
|
||||||
|
@ -113,7 +130,7 @@ def download_release(package, id):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
|
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
|
||||||
if ip is not None and not is_user_bot():
|
if ip is not None:
|
||||||
key = make_download_key(ip, release.package)
|
key = make_download_key(ip, release.package)
|
||||||
if not has_key(key):
|
if not has_key(key):
|
||||||
set_key(key, "true")
|
set_key(key, "true")
|
||||||
|
@ -132,21 +149,21 @@ def download_release(package, id):
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(release.url)
|
return redirect(release.url, code=300)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def edit_release(package, id):
|
def edit_release(package, id):
|
||||||
release : PackageRelease = PackageRelease.query.get(id)
|
release = PackageRelease.query.get(id)
|
||||||
if release is None or release.package != package:
|
if release is None or release.package != package:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
|
canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
|
||||||
canApprove = release.checkPerm(current_user, Permission.APPROVE_RELEASE)
|
canApprove = package.checkPerm(current_user, Permission.APPROVE_RELEASE)
|
||||||
if not (canEdit or canApprove):
|
if not (canEdit or canApprove):
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
# Initial form class from post data and default data
|
# Initial form class from post data and default data
|
||||||
form = EditPackageReleaseForm(formdata=request.form, obj=release)
|
form = EditPackageReleaseForm(formdata=request.form, obj=release)
|
||||||
|
@ -155,7 +172,8 @@ def edit_release(package, id):
|
||||||
# HACK: fix bug in wtforms
|
# HACK: fix bug in wtforms
|
||||||
form.approved.data = release.approved
|
form.approved.data = release.approved
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if request.method == "POST" and form.validate():
|
||||||
|
wasApproved = release.approved
|
||||||
if canEdit:
|
if canEdit:
|
||||||
release.title = form["title"].data
|
release.title = form["title"].data
|
||||||
release.min_rel = form["min_rel"].data.getActual()
|
release.min_rel = form["min_rel"].data.getActual()
|
||||||
|
@ -167,27 +185,27 @@ def edit_release(package, id):
|
||||||
if release.task_id is not None:
|
if release.task_id is not None:
|
||||||
release.task_id = None
|
release.task_id = None
|
||||||
|
|
||||||
if form.approved.data:
|
if canApprove:
|
||||||
release.approve(current_user)
|
release.approved = form["approved"].data
|
||||||
elif canApprove:
|
else:
|
||||||
release.approved = False
|
release.approved = wasApproved
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(package.getURL("packages.list_releases"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
return render_template("packages/release_edit.html", package=package, release=release, form=form)
|
return render_template("packages/release_edit.html", package=package, release=release, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class BulkReleaseForm(FlaskForm):
|
class BulkReleaseForm(FlaskForm):
|
||||||
set_min = BooleanField(lazy_gettext("Set Min"))
|
set_min = BooleanField("Set Min")
|
||||||
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
|
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
|
||||||
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||||
set_max = BooleanField(lazy_gettext("Set Max"))
|
set_max = BooleanField("Set Max")
|
||||||
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
|
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
|
||||||
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
|
||||||
only_change_none = BooleanField(lazy_gettext("Only change values previously set as none"))
|
only_change_none = BooleanField("Only change values previously set as none")
|
||||||
submit = SubmitField(lazy_gettext("Update"))
|
submit = SubmitField("Update")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
|
||||||
|
@ -195,14 +213,14 @@ class BulkReleaseForm(FlaskForm):
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def bulk_change_release(package):
|
def bulk_change_release(package):
|
||||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
# Initial form class from post data and default data
|
# Initial form class from post data and default data
|
||||||
form = BulkReleaseForm()
|
form = BulkReleaseForm()
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
form.only_change_none.data = True
|
form.only_change_none.data = True
|
||||||
elif form.validate_on_submit():
|
elif request.method == "POST" and form.validate():
|
||||||
only_change_none = form.only_change_none.data
|
only_change_none = form.only_change_none.data
|
||||||
|
|
||||||
for release in package.releases.all():
|
for release in package.releases.all():
|
||||||
|
@ -213,7 +231,7 @@ def bulk_change_release(package):
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(package.getURL("packages.list_releases"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
return render_template("packages/release_bulk_change.html", package=package, form=form)
|
return render_template("packages/release_bulk_change.html", package=package, form=form)
|
||||||
|
|
||||||
|
@ -227,132 +245,9 @@ def delete_release(package, id):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
|
if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
|
||||||
return redirect(package.getURL("packages.list_releases"))
|
return redirect(release.getEditURL())
|
||||||
|
|
||||||
db.session.delete(release)
|
db.session.delete(release)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
|
|
||||||
class PackageUpdateConfigFrom(FlaskForm):
|
|
||||||
trigger = RadioField(lazy_gettext("Trigger"), [InputRequired()],
|
|
||||||
choices=[(PackageUpdateTrigger.COMMIT, lazy_gettext("New Commit")),
|
|
||||||
(PackageUpdateTrigger.TAG, lazy_gettext("New Tag"))],
|
|
||||||
coerce=PackageUpdateTrigger.coerce, default=PackageUpdateTrigger.TAG)
|
|
||||||
ref = StringField(lazy_gettext("Branch name"), [Optional()], default=None)
|
|
||||||
action = RadioField(lazy_gettext("Action"), [InputRequired()],
|
|
||||||
choices=[("notification", lazy_gettext("Send notification and mark as outdated")), ("make_release", lazy_gettext("Create release"))],
|
|
||||||
default="make_release")
|
|
||||||
submit = SubmitField(lazy_gettext("Save Settings"))
|
|
||||||
disable = SubmitField(lazy_gettext("Disable Automation"))
|
|
||||||
|
|
||||||
|
|
||||||
def set_update_config(package, form):
|
|
||||||
if package.update_config is None:
|
|
||||||
package.update_config = PackageUpdateConfig()
|
|
||||||
db.session.add(package.update_config)
|
|
||||||
|
|
||||||
form.populate_obj(package.update_config)
|
|
||||||
package.update_config.ref = nonEmptyOrNone(form.ref.data)
|
|
||||||
package.update_config.make_release = form.action.data == "make_release"
|
|
||||||
|
|
||||||
if package.update_config.trigger == PackageUpdateTrigger.COMMIT:
|
|
||||||
if package.update_config.last_commit is None:
|
|
||||||
last_release = package.releases.first()
|
|
||||||
if last_release and last_release.commit_hash:
|
|
||||||
package.update_config.last_commit = last_release.commit_hash
|
|
||||||
elif package.update_config.trigger == PackageUpdateTrigger.TAG:
|
|
||||||
# Only create releases for tags created after this
|
|
||||||
package.update_config.last_commit = None
|
|
||||||
package.update_config.last_tag = None
|
|
||||||
|
|
||||||
package.update_config.outdated_at = None
|
|
||||||
package.update_config.auto_created = False
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if package.update_config.last_commit is None:
|
|
||||||
check_update_config.delay(package.id)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/update-config/", methods=["GET", "POST"])
|
|
||||||
@login_required
|
|
||||||
@is_package_page
|
|
||||||
def update_config(package):
|
|
||||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
if not package.repo:
|
|
||||||
flash(gettext("Please add a Git repository URL in order to set up automatic releases"), "danger")
|
|
||||||
return redirect(package.getURL("packages.create_edit"))
|
|
||||||
|
|
||||||
form = PackageUpdateConfigFrom(obj=package.update_config)
|
|
||||||
if request.method == "GET":
|
|
||||||
if package.update_config:
|
|
||||||
form.action.data = "make_release" if package.update_config.make_release else "notification"
|
|
||||||
elif request.args.get("action") == "notification":
|
|
||||||
form.trigger.data = PackageUpdateTrigger.COMMIT
|
|
||||||
form.action.data = "notification"
|
|
||||||
|
|
||||||
if "trigger" in request.args:
|
|
||||||
form.trigger.data = PackageUpdateTrigger.get(request.args["trigger"])
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
if form.disable.data:
|
|
||||||
flash(gettext("Deleted update configuration"), "success")
|
|
||||||
if package.update_config:
|
|
||||||
db.session.delete(package.update_config)
|
|
||||||
db.session.commit()
|
|
||||||
else:
|
|
||||||
set_update_config(package, form)
|
|
||||||
|
|
||||||
if not form.disable.data and package.releases.count() == 0:
|
|
||||||
flash(gettext("Now, please create an initial release"), "success")
|
|
||||||
return redirect(package.getURL("packages.create_release"))
|
|
||||||
|
|
||||||
return redirect(package.getURL("packages.list_releases"))
|
|
||||||
|
|
||||||
return render_template("packages/update_config.html", package=package, form=form)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/setup-releases/")
|
|
||||||
@login_required
|
|
||||||
@is_package_page
|
|
||||||
def setup_releases(package):
|
|
||||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
if package.update_config:
|
|
||||||
return redirect(package.getURL("packages.update_config"))
|
|
||||||
|
|
||||||
return render_template("packages/release_wizard.html", package=package)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/update-configs/")
|
|
||||||
@bp.route("/users/<username>/update-configs/", methods=["GET", "POST"])
|
|
||||||
@login_required
|
|
||||||
def bulk_update_config(username=None):
|
|
||||||
if username is None:
|
|
||||||
return redirect(url_for("packages.bulk_update_config", username=current_user.username))
|
|
||||||
|
|
||||||
user: User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
form = PackageUpdateConfigFrom()
|
|
||||||
if form.validate_on_submit():
|
|
||||||
for package in user.packages.filter(Package.state != PackageState.DELETED, Package.repo.isnot(None)).all():
|
|
||||||
set_update_config(package, form)
|
|
||||||
|
|
||||||
return redirect(url_for("packages.bulk_update_config", username=username))
|
|
||||||
|
|
||||||
confs = user.packages \
|
|
||||||
.filter(Package.state != PackageState.DELETED,
|
|
||||||
Package.update_config.has()) \
|
|
||||||
.order_by(db.asc(Package.title)).all()
|
|
||||||
|
|
||||||
return render_template("packages/bulk_update_conf.html", user=user, confs=confs, form=form)
|
|
||||||
|
|
|
@ -2,57 +2,48 @@
|
||||||
# Copyright (C) 2020 rubenwardy
|
# Copyright (C) 2020 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
from flask_babel import gettext, lazy_gettext
|
|
||||||
|
|
||||||
from . import bp
|
from . import bp
|
||||||
|
|
||||||
from flask import *
|
from flask import *
|
||||||
from flask_login import current_user, login_required
|
from flask_user import *
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import *
|
from wtforms import *
|
||||||
from wtforms.validators import *
|
from wtforms.validators import *
|
||||||
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
|
from app.models import db, PackageReview, Thread, ThreadReply
|
||||||
Permission, AuditSeverity
|
from app.utils import is_package_page, addNotification
|
||||||
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required, addAuditLog
|
|
||||||
from app.tasks.webhooktasks import post_discord_webhook
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/reviews/")
|
@bp.route("/reviews/")
|
||||||
def list_reviews():
|
def list_reviews():
|
||||||
page = get_int_or_abort(request.args.get("page"), 1)
|
reviews = PackageReview.query.order_by(db.desc(PackageReview.created_at)).all()
|
||||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
return render_template("packages/reviews_list.html", reviews=reviews)
|
||||||
|
|
||||||
pagination = PackageReview.query.order_by(db.desc(PackageReview.created_at)).paginate(page, num, True)
|
|
||||||
return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items)
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewForm(FlaskForm):
|
class ReviewForm(FlaskForm):
|
||||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
|
title = StringField("Title", [InputRequired(), Length(3,100)])
|
||||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
comment = TextAreaField("Comment", [InputRequired(), Length(10, 500)])
|
||||||
recommends = RadioField(lazy_gettext("Private"), [InputRequired()],
|
recommends = RadioField("Private", [InputRequired()], choices=[("yes", "Yes"), ("no", "No")])
|
||||||
choices=[("yes", lazy_gettext("Yes")), ("no", lazy_gettext("No"))])
|
submit = SubmitField("Save")
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/review/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/review/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def review(package):
|
def review(package):
|
||||||
if current_user in package.maintainers:
|
if current_user in package.maintainers:
|
||||||
flash(gettext("You can't review your own package!"), "danger")
|
flash("You can't review your own package!", "danger")
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||||
|
|
||||||
|
@ -65,7 +56,7 @@ def review(package):
|
||||||
form.comment.data = review.thread.replies[0].comment
|
form.comment.data = review.thread.replies[0].comment
|
||||||
|
|
||||||
# Validate and submit
|
# Validate and submit
|
||||||
elif form.validate_on_submit():
|
elif request.method == "POST" and form.validate():
|
||||||
was_new = False
|
was_new = False
|
||||||
if not review:
|
if not review:
|
||||||
was_new = True
|
was_new = True
|
||||||
|
@ -104,41 +95,30 @@ def review(package):
|
||||||
|
|
||||||
package.recalcScore()
|
package.recalcScore()
|
||||||
|
|
||||||
|
notif_msg = None
|
||||||
if was_new:
|
if was_new:
|
||||||
notif_msg = "New review '{}'".format(form.title.data)
|
notif_msg = "New review '{}'".format(form.title.data)
|
||||||
type = NotificationType.NEW_REVIEW
|
|
||||||
else:
|
else:
|
||||||
notif_msg = "Updated review '{}'".format(form.title.data)
|
notif_msg = "Updated review '{}'".format(form.title.data)
|
||||||
type = NotificationType.OTHER
|
|
||||||
|
|
||||||
addNotification(package.maintainers, current_user, type, notif_msg,
|
addNotification(package.maintainers, current_user, notif_msg, url_for("threads.view", id=thread.id), package)
|
||||||
url_for("threads.view", id=thread.id), package)
|
|
||||||
|
|
||||||
if was_new:
|
|
||||||
post_discord_webhook.delay(thread.author.username,
|
|
||||||
"Reviewed {}: {}".format(package.title, thread.getViewURL(absolute=True)), False)
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
return render_template("packages/review_create_edit.html",
|
return render_template("packages/review_create_edit.html", \
|
||||||
form=form, package=package, review=review)
|
form=form, package=package, review=review)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/reviews/<reviewer>/delete/", methods=["POST"])
|
@bp.route("/packages/<author>/<name>/review/delete/", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def delete_review(package, reviewer):
|
def delete_review(package):
|
||||||
review = PackageReview.query \
|
review = PackageReview.query.filter_by(package=package, author=current_user).first()
|
||||||
.filter(PackageReview.package == package, PackageReview.author.has(username=reviewer)) \
|
|
||||||
.first()
|
|
||||||
if review is None or review.package != package:
|
if review is None or review.package != package:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if not review.checkPerm(current_user, Permission.DELETE_REVIEW):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
thread = review.thread
|
thread = review.thread
|
||||||
|
|
||||||
reply = ThreadReply()
|
reply = ThreadReply()
|
||||||
|
@ -149,92 +129,10 @@ def delete_review(package, reviewer):
|
||||||
|
|
||||||
thread.review = None
|
thread.review = None
|
||||||
|
|
||||||
msg = "Converted review by {} to thread".format(review.author.display_name)
|
|
||||||
addAuditLog(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
|
|
||||||
current_user, msg, thread.getViewURL(), thread.package)
|
|
||||||
|
|
||||||
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
|
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
|
||||||
addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
|
addNotification(package.maintainers, current_user, notif_msg, url_for("threads.view", id=thread.id), package)
|
||||||
|
|
||||||
db.session.delete(review)
|
db.session.delete(review)
|
||||||
|
|
||||||
package.recalcScore()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(thread.getViewURL())
|
return redirect(thread.getViewURL())
|
||||||
|
|
||||||
|
|
||||||
def handle_review_vote(package: Package, review_id: int):
|
|
||||||
if current_user in package.maintainers:
|
|
||||||
flash(gettext("You can't vote on the reviews on your own package!"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
review: PackageReview = PackageReview.query.get(review_id)
|
|
||||||
if review is None or review.package != package:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if review.author == current_user:
|
|
||||||
flash(gettext("You can't vote on your own reviews!"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
is_positive = isYes(request.form["is_positive"])
|
|
||||||
|
|
||||||
vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first()
|
|
||||||
if vote is None:
|
|
||||||
vote = PackageReviewVote()
|
|
||||||
vote.review = review
|
|
||||||
vote.user = current_user
|
|
||||||
vote.is_positive = is_positive
|
|
||||||
db.session.add(vote)
|
|
||||||
elif vote.is_positive == is_positive:
|
|
||||||
db.session.delete(vote)
|
|
||||||
else:
|
|
||||||
vote.is_positive = is_positive
|
|
||||||
|
|
||||||
review.update_score()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/review/<int:review_id>/", methods=["POST"])
|
|
||||||
@login_required
|
|
||||||
@is_package_page
|
|
||||||
def review_vote(package, review_id):
|
|
||||||
handle_review_vote(package, review_id)
|
|
||||||
|
|
||||||
next_url = request.args.get("r")
|
|
||||||
if next_url and is_safe_url(next_url):
|
|
||||||
return redirect(next_url)
|
|
||||||
else:
|
|
||||||
return redirect(review.thread.getViewURL())
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/review-votes/")
|
|
||||||
@rank_required(UserRank.ADMIN)
|
|
||||||
@is_package_page
|
|
||||||
def review_votes(package):
|
|
||||||
user_biases = {}
|
|
||||||
for review in package.reviews:
|
|
||||||
review_sign = 1 if review.recommends else -1
|
|
||||||
for vote in review.votes:
|
|
||||||
user_biases[vote.user.username] = user_biases.get(vote.user.username, [0, 0])
|
|
||||||
vote_sign = 1 if vote.is_positive else -1
|
|
||||||
vote_bias = review_sign * vote_sign
|
|
||||||
if vote_bias == 1:
|
|
||||||
user_biases[vote.user.username][0] += 1
|
|
||||||
else:
|
|
||||||
user_biases[vote.user.username][1] += 1
|
|
||||||
|
|
||||||
BiasInfo = namedtuple("BiasInfo", "username balance with_ against no_vote perc_with")
|
|
||||||
user_biases_info = []
|
|
||||||
for username, bias in user_biases.items():
|
|
||||||
total_votes = bias[0] + bias[1]
|
|
||||||
balance = bias[0] - bias[1]
|
|
||||||
perc_with = round((100 * bias[0]) / total_votes)
|
|
||||||
user_biases_info.append(BiasInfo(username, balance, bias[0], bias[1], len(package.reviews) - total_votes, perc_with))
|
|
||||||
|
|
||||||
user_biases_info.sort(key=lambda x: -abs(x.balance))
|
|
||||||
|
|
||||||
return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews,
|
|
||||||
user_biases=user_biases_info)
|
|
||||||
|
|
|
@ -1,100 +1,73 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask import *
|
from flask import *
|
||||||
from flask_babel import gettext, lazy_gettext
|
from flask_user import *
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from flask_login import login_required
|
|
||||||
from wtforms import *
|
|
||||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
|
||||||
from wtforms.validators import *
|
|
||||||
|
|
||||||
|
from . import bp
|
||||||
|
|
||||||
|
from app.models import *
|
||||||
from app.utils import *
|
from app.utils import *
|
||||||
from . import bp, get_package_tabs
|
|
||||||
from app.logic.LogicError import LogicError
|
from flask_wtf import FlaskForm
|
||||||
from app.logic.screenshots import do_create_screenshot, do_order_screenshots
|
from wtforms import *
|
||||||
|
from wtforms.validators import *
|
||||||
|
|
||||||
|
|
||||||
class CreateScreenshotForm(FlaskForm):
|
class CreateScreenshotForm(FlaskForm):
|
||||||
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
title = StringField("Title/Caption", [Optional()])
|
||||||
fileUpload = FileField(lazy_gettext("File Upload"), [InputRequired()])
|
fileUpload = FileField("File Upload", [InputRequired()])
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
class EditScreenshotForm(FlaskForm):
|
class EditScreenshotForm(FlaskForm):
|
||||||
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
|
title = StringField("Title/Caption", [Optional()])
|
||||||
approved = BooleanField(lazy_gettext("Is Approved"))
|
approved = BooleanField("Is Approved")
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
delete = BooleanField("Delete")
|
||||||
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
class EditPackageScreenshotsForm(FlaskForm):
|
|
||||||
cover_image = QuerySelectField(lazy_gettext("Cover Image"), [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/screenshots/", methods=["GET", "POST"])
|
|
||||||
@login_required
|
|
||||||
@is_package_page
|
|
||||||
def screenshots(package):
|
|
||||||
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
|
||||||
return redirect(package.getURL("packages.view"))
|
|
||||||
|
|
||||||
if package.screenshots.count() == 0:
|
|
||||||
return redirect(package.getURL("packages.create_screenshot"))
|
|
||||||
|
|
||||||
form = EditPackageScreenshotsForm(obj=package)
|
|
||||||
form.cover_image.query = package.screenshots
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
order = request.form.get("order")
|
|
||||||
if order:
|
|
||||||
try:
|
|
||||||
do_order_screenshots(current_user, package, order.split(","))
|
|
||||||
return redirect(package.getURL("packages.view"))
|
|
||||||
except LogicError as e:
|
|
||||||
flash(e.message, "danger")
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
form.populate_obj(package)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return render_template("packages/screenshots.html", package=package, form=form,
|
|
||||||
tabs=get_package_tabs(current_user, package), current_tab="screenshots")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/screenshots/new/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/screenshots/new/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@is_package_page
|
@is_package_page
|
||||||
def create_screenshot(package):
|
def create_screenshot(package, id=None):
|
||||||
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
# Initial form class from post data and default data
|
# Initial form class from post data and default data
|
||||||
form = CreateScreenshotForm()
|
form = CreateScreenshotForm()
|
||||||
if form.validate_on_submit():
|
if request.method == "POST" and form.validate():
|
||||||
try:
|
uploadedUrl, uploadedPath = doFileUpload(form.fileUpload.data, "image",
|
||||||
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data, False)
|
"a PNG or JPG image file")
|
||||||
return redirect(package.getURL("packages.screenshots"))
|
if uploadedUrl is not None:
|
||||||
except LogicError as e:
|
ss = PackageScreenshot()
|
||||||
flash(e.message, "danger")
|
ss.package = package
|
||||||
|
ss.title = form["title"].data or "Untitled"
|
||||||
|
ss.url = uploadedUrl
|
||||||
|
ss.approved = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
|
||||||
|
db.session.add(ss)
|
||||||
|
|
||||||
|
msg = "Screenshot added {}" \
|
||||||
|
.format(ss.title)
|
||||||
|
addNotification(package.maintainers, current_user, msg, package.getDetailsURL(), package)
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
return render_template("packages/screenshot_new.html", package=package, form=form)
|
return render_template("packages/screenshot_new.html", package=package, form=form)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/screenshots/<id>/edit/", methods=["GET", "POST"])
|
@bp.route("/packages/<author>/<name>/screenshots/<id>/edit/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@is_package_page
|
@is_package_page
|
||||||
|
@ -106,44 +79,31 @@ def edit_screenshot(package, id):
|
||||||
canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
|
canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
|
||||||
canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
|
canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
|
||||||
if not (canEdit or canApprove):
|
if not (canEdit or canApprove):
|
||||||
return redirect(package.getURL("packages.screenshots"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
# Initial form class from post data and default data
|
# Initial form class from post data and default data
|
||||||
form = EditScreenshotForm(obj=screenshot)
|
form = EditScreenshotForm(formdata=request.form, obj=screenshot)
|
||||||
if form.validate_on_submit():
|
|
||||||
wasApproved = screenshot.approved
|
|
||||||
|
|
||||||
if canEdit:
|
if request.method == "GET":
|
||||||
screenshot.title = form["title"].data or "Untitled"
|
# HACK: fix bug in wtforms
|
||||||
|
form.approved.data = screenshot.approved
|
||||||
|
|
||||||
|
if request.method == "POST" and form.validate():
|
||||||
|
if canEdit and form["delete"].data:
|
||||||
|
PackageScreenshot.query.filter_by(id=id).delete()
|
||||||
|
|
||||||
if canApprove:
|
|
||||||
screenshot.approved = form["approved"].data
|
|
||||||
else:
|
else:
|
||||||
screenshot.approved = wasApproved
|
wasApproved = screenshot.approved
|
||||||
|
|
||||||
|
if canEdit:
|
||||||
|
screenshot.title = form["title"].data or "Untitled"
|
||||||
|
|
||||||
|
if canApprove:
|
||||||
|
screenshot.approved = form["approved"].data
|
||||||
|
else:
|
||||||
|
screenshot.approved = wasApproved
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(package.getURL("packages.screenshots"))
|
return redirect(package.getDetailsURL())
|
||||||
|
|
||||||
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
|
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/packages/<author>/<name>/screenshots/<id>/delete/", methods=["POST"])
|
|
||||||
@login_required
|
|
||||||
@is_package_page
|
|
||||||
def delete_screenshot(package, id):
|
|
||||||
screenshot = PackageScreenshot.query.get(id)
|
|
||||||
if screenshot is None or screenshot.package != package:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
|
|
||||||
flash(gettext("Permission denied"), "danger")
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
if package.cover_image == screenshot:
|
|
||||||
package.cover_image = None
|
|
||||||
db.session.merge(package)
|
|
||||||
|
|
||||||
db.session.delete(screenshot)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(package.getURL("packages.screenshots"))
|
|
||||||
|
|
|
@ -1,64 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2022 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from flask import Blueprint, request, render_template, url_for
|
|
||||||
from flask_babel import lazy_gettext
|
|
||||||
from flask_login import current_user
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from werkzeug.utils import redirect
|
|
||||||
from wtforms import TextAreaField, SubmitField
|
|
||||||
from wtforms.validators import InputRequired, Length
|
|
||||||
|
|
||||||
from app.models import User, UserRank
|
|
||||||
from app.tasks.emails import send_user_email
|
|
||||||
from app.tasks.webhooktasks import post_discord_webhook
|
|
||||||
from app.utils import isNo, abs_url_samesite
|
|
||||||
|
|
||||||
bp = Blueprint("report", __name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ReportForm(FlaskForm):
|
|
||||||
message = TextAreaField(lazy_gettext("Message"), [InputRequired(), Length(10, 10000)])
|
|
||||||
submit = SubmitField(lazy_gettext("Report"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/report/", methods=["GET", "POST"])
|
|
||||||
def report():
|
|
||||||
is_anon = not current_user.is_authenticated or not isNo(request.args.get("anon"))
|
|
||||||
|
|
||||||
url = request.args.get("url")
|
|
||||||
if url:
|
|
||||||
url = abs_url_samesite(url)
|
|
||||||
|
|
||||||
form = ReportForm(formdata=request.form)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
user_info = f"{current_user.username}"
|
|
||||||
else:
|
|
||||||
user_info = request.headers.get("X-Forwarded-For") or request.remote_addr
|
|
||||||
|
|
||||||
text = f"{url}\n\n{form.message.data}"
|
|
||||||
|
|
||||||
task = None
|
|
||||||
for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
|
|
||||||
task = send_user_email.delay(admin.email, admin.locale or "en",
|
|
||||||
f"User report from {user_info}", text)
|
|
||||||
|
|
||||||
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
|
|
||||||
|
|
||||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
|
|
||||||
|
|
||||||
return render_template("report/index.html", form=form, url=url, is_anon=is_anon)
|
|
|
@ -1,26 +1,28 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from flask import *
|
from flask import *
|
||||||
from flask_login import login_required
|
from flask_user import *
|
||||||
|
import flask_menu as menu
|
||||||
from app import csrf
|
from app import csrf
|
||||||
from app.tasks import celery
|
from app.models import *
|
||||||
|
from app.tasks import celery, TaskError
|
||||||
from app.tasks.importtasks import getMeta
|
from app.tasks.importtasks import getMeta
|
||||||
|
from app.utils import shouldReturnJson
|
||||||
from app.utils import *
|
from app.utils import *
|
||||||
|
|
||||||
bp = Blueprint("tasks", __name__)
|
bp = Blueprint("tasks", __name__)
|
||||||
|
@ -43,7 +45,7 @@ def check(id):
|
||||||
traceback = result.traceback
|
traceback = result.traceback
|
||||||
result = result.result
|
result = result.result
|
||||||
|
|
||||||
None
|
info = None
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
info = {
|
info = {
|
||||||
'id': id,
|
'id': id,
|
||||||
|
|
|
@ -1,35 +1,35 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from flask import *
|
|
||||||
from flask_babel import gettext, lazy_gettext
|
|
||||||
|
|
||||||
from app.markdown import get_user_mentions, render_markdown
|
|
||||||
from app.tasks.webhooktasks import post_discord_webhook
|
from flask import *
|
||||||
|
|
||||||
bp = Blueprint("threads", __name__)
|
bp = Blueprint("threads", __name__)
|
||||||
|
|
||||||
from flask_login import current_user, login_required
|
from flask_user import *
|
||||||
from app.models import *
|
from app.models import *
|
||||||
from app.utils import addNotification, isYes, addAuditLog, get_system_user
|
from app.utils import addNotification, clearNotifications, isYes, addAuditLog
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import *
|
from wtforms import *
|
||||||
from wtforms.validators import *
|
from wtforms.validators import *
|
||||||
from app.utils import get_int_or_abort
|
from app.utils import get_int_or_abort
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/threads/")
|
@bp.route("/threads/")
|
||||||
def list_all():
|
def list_all():
|
||||||
query = Thread.query
|
query = Thread.query
|
||||||
|
@ -41,16 +41,9 @@ def list_all():
|
||||||
pid = get_int_or_abort(pid)
|
pid = get_int_or_abort(pid)
|
||||||
query = query.filter_by(package_id=pid)
|
query = query.filter_by(package_id=pid)
|
||||||
|
|
||||||
query = query.filter_by(review_id=None)
|
|
||||||
|
|
||||||
query = query.order_by(db.desc(Thread.created_at))
|
query = query.order_by(db.desc(Thread.created_at))
|
||||||
|
|
||||||
page = get_int_or_abort(request.args.get("page"), 1)
|
return render_template("threads/list.html", threads=query.all())
|
||||||
num = min(40, get_int_or_abort(request.args.get("n"), 100))
|
|
||||||
|
|
||||||
pagination = query.paginate(page, num, True)
|
|
||||||
|
|
||||||
return render_template("threads/list.html", pagination=pagination, threads=pagination.items)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
|
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
|
||||||
|
@ -61,9 +54,9 @@ def subscribe(id):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if current_user in thread.watchers:
|
if current_user in thread.watchers:
|
||||||
flash(gettext("Already subscribed!"), "success")
|
flash("Already subscribed!", "success")
|
||||||
else:
|
else:
|
||||||
flash(gettext("Subscribed to thread"), "success")
|
flash("Subscribed to thread", "success")
|
||||||
thread.watchers.append(current_user)
|
thread.watchers.append(current_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@ -78,11 +71,11 @@ def unsubscribe(id):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if current_user in thread.watchers:
|
if current_user in thread.watchers:
|
||||||
flash(gettext("Unsubscribed!"), "success")
|
flash("Unsubscribed!", "success")
|
||||||
thread.watchers.remove(current_user)
|
thread.watchers.remove(current_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
else:
|
else:
|
||||||
flash(gettext("Already not subscribed!"), "success")
|
flash("Already not subscribed!", "success")
|
||||||
|
|
||||||
return redirect(thread.getViewURL())
|
return redirect(thread.getViewURL())
|
||||||
|
|
||||||
|
@ -101,12 +94,12 @@ def set_lock(id):
|
||||||
msg = None
|
msg = None
|
||||||
if thread.locked:
|
if thread.locked:
|
||||||
msg = "Locked thread '{}'".format(thread.title)
|
msg = "Locked thread '{}'".format(thread.title)
|
||||||
flash(gettext("Locked thread"), "success")
|
flash("Locked thread", "success")
|
||||||
else:
|
else:
|
||||||
msg = "Unlocked thread '{}'".format(thread.title)
|
msg = "Unlocked thread '{}'".format(thread.title)
|
||||||
flash(gettext("Unlocked thread"), "success")
|
flash("Unlocked thread", "success")
|
||||||
|
|
||||||
addNotification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
|
addNotification(thread.watchers, current_user, msg, thread.getViewURL(), thread.package)
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package)
|
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
@ -116,29 +109,6 @@ def set_lock(id):
|
||||||
|
|
||||||
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
|
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_thread(id):
|
|
||||||
thread = Thread.query.get(id)
|
|
||||||
if thread is None or not thread.checkPerm(current_user, Permission.DELETE_THREAD):
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
return render_template("threads/delete_thread.html", thread=thread)
|
|
||||||
|
|
||||||
summary = "\n\n".join([("<{}> {}".format(reply.author.display_name, reply.comment)) for reply in thread.replies])
|
|
||||||
|
|
||||||
msg = "Deleted thread {} by {}".format(thread.title, thread.author.display_name)
|
|
||||||
|
|
||||||
db.session.delete(thread)
|
|
||||||
|
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/threads/<int:id>/delete-reply/", methods=["GET", "POST"])
|
|
||||||
@login_required
|
|
||||||
def delete_reply(id):
|
def delete_reply(id):
|
||||||
thread = Thread.query.get(id)
|
thread = Thread.query.get(id)
|
||||||
if thread is None:
|
if thread is None:
|
||||||
|
@ -153,7 +123,7 @@ def delete_reply(id):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if thread.replies[0] == reply:
|
if thread.replies[0] == reply:
|
||||||
flash(gettext("Cannot delete thread opening post!"), "danger")
|
flash("Cannot delete thread opening post!", "danger")
|
||||||
return redirect(thread.getViewURL())
|
return redirect(thread.getViewURL())
|
||||||
|
|
||||||
if not reply.checkPerm(current_user, Permission.DELETE_REPLY):
|
if not reply.checkPerm(current_user, Permission.DELETE_REPLY):
|
||||||
|
@ -171,9 +141,12 @@ def delete_reply(id):
|
||||||
return redirect(thread.getViewURL())
|
return redirect(thread.getViewURL())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CommentForm(FlaskForm):
|
class CommentForm(FlaskForm):
|
||||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
comment = TextAreaField("Comment", [InputRequired(), Length(10, 500)])
|
||||||
submit = SubmitField(lazy_gettext("Comment"))
|
submit = SubmitField("Comment")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
|
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
|
||||||
|
@ -195,12 +168,12 @@ def edit_reply(id):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
form = CommentForm(formdata=request.form, obj=reply)
|
form = CommentForm(formdata=request.form, obj=reply)
|
||||||
if form.validate_on_submit():
|
if request.method == "POST" and form.validate():
|
||||||
comment = form.comment.data
|
comment = form.comment.data
|
||||||
|
|
||||||
msg = "Edited reply by {}".format(reply.author.display_name)
|
msg = "Edited reply by {}".format(reply.author.display_name)
|
||||||
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
|
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
|
||||||
addNotification(reply.author, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
|
addNotification(reply.author, current_user, msg, thread.getViewURL(), thread.package)
|
||||||
addAuditLog(severity, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
|
addAuditLog(severity, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
|
||||||
|
|
||||||
reply.comment = comment
|
reply.comment = comment
|
||||||
|
@ -214,7 +187,7 @@ def edit_reply(id):
|
||||||
|
|
||||||
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
|
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
|
||||||
def view(id):
|
def view(id):
|
||||||
thread: Thread = Thread.query.get(id)
|
thread = Thread.query.get(id)
|
||||||
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
|
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
@ -222,14 +195,14 @@ def view(id):
|
||||||
comment = request.form["comment"]
|
comment = request.form["comment"]
|
||||||
|
|
||||||
if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
|
if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
|
||||||
flash(gettext("You cannot comment on this thread"), "danger")
|
flash("You cannot comment on this thread", "danger")
|
||||||
return redirect(thread.getViewURL())
|
return redirect(thread.getViewURL())
|
||||||
|
|
||||||
if not current_user.canCommentRL():
|
if not current_user.canCommentRL():
|
||||||
flash(gettext("Please wait before commenting again"), "danger")
|
flash("Please wait before commenting again", "danger")
|
||||||
return redirect(thread.getViewURL())
|
return redirect(thread.getViewURL())
|
||||||
|
|
||||||
if 2000 >= len(comment) > 3:
|
if len(comment) <= 500 and len(comment) > 3:
|
||||||
reply = ThreadReply()
|
reply = ThreadReply()
|
||||||
reply.author = current_user
|
reply.author = current_user
|
||||||
reply.comment = comment
|
reply.comment = comment
|
||||||
|
@ -239,40 +212,23 @@ def view(id):
|
||||||
if not current_user in thread.watchers:
|
if not current_user in thread.watchers:
|
||||||
thread.watchers.append(current_user)
|
thread.watchers.append(current_user)
|
||||||
|
|
||||||
for mentioned_username in get_user_mentions(render_markdown(comment)):
|
|
||||||
mentioned = User.query.filter_by(username=mentioned_username)
|
|
||||||
if mentioned is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
|
|
||||||
addNotification(mentioned, current_user, NotificationType.THREAD_REPLY,
|
|
||||||
msg, thread.getViewURL(), thread.package)
|
|
||||||
|
|
||||||
msg = "New comment on '{}'".format(thread.title)
|
msg = "New comment on '{}'".format(thread.title)
|
||||||
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
|
addNotification(thread.watchers, current_user, msg, thread.getViewURL(), thread.package)
|
||||||
|
|
||||||
if thread.author == get_system_user():
|
|
||||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
|
||||||
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
|
|
||||||
thread.getViewURL(), thread.package)
|
|
||||||
post_discord_webhook.delay(current_user.username,
|
|
||||||
"Replied to bot messages: {}".format(thread.getViewURL(absolute=True)), True)
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(thread.getViewURL())
|
return redirect(thread.getViewURL())
|
||||||
|
|
||||||
else:
|
else:
|
||||||
flash(gettext("Comment needs to be between 3 and 2000 characters."), "danger")
|
flash("Comment needs to be between 3 and 500 characters.")
|
||||||
|
|
||||||
return render_template("threads/view.html", thread=thread)
|
return render_template("threads/view.html", thread=thread)
|
||||||
|
|
||||||
|
|
||||||
class ThreadForm(FlaskForm):
|
class ThreadForm(FlaskForm):
|
||||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
|
title = StringField("Title", [InputRequired(), Length(3,100)])
|
||||||
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
|
comment = TextAreaField("Comment", [InputRequired(), Length(10, 500)])
|
||||||
private = BooleanField(lazy_gettext("Private"))
|
private = BooleanField("Private")
|
||||||
submit = SubmitField(lazy_gettext("Open Thread"))
|
submit = SubmitField("Open Thread")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/threads/new/", methods=["GET", "POST"])
|
@bp.route("/threads/new/", methods=["GET", "POST"])
|
||||||
|
@ -284,7 +240,7 @@ def new():
|
||||||
if "pid" in request.args:
|
if "pid" in request.args:
|
||||||
package = Package.query.get(int(request.args.get("pid")))
|
package = Package.query.get(int(request.args.get("pid")))
|
||||||
if package is None:
|
if package is None:
|
||||||
flash(gettext("Unable to find that package!"), "danger")
|
flash("Unable to find that package!", "danger")
|
||||||
|
|
||||||
# Don't allow making orphan threads on approved packages for now
|
# Don't allow making orphan threads on approved packages for now
|
||||||
if package is None:
|
if package is None:
|
||||||
|
@ -298,19 +254,19 @@ def new():
|
||||||
|
|
||||||
# Check that user can make the thread
|
# Check that user can make the thread
|
||||||
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
|
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
|
||||||
flash(gettext("Unable to create thread!"), "danger")
|
flash("Unable to create thread!", "danger")
|
||||||
return redirect(url_for("homepage.home"))
|
return redirect(url_for("homepage.home"))
|
||||||
|
|
||||||
# Only allow creating one thread when not approved
|
# Only allow creating one thread when not approved
|
||||||
elif is_review_thread and package.review_thread is not None:
|
elif is_review_thread and package.review_thread is not None:
|
||||||
flash(gettext("An approval thread already exists!"), "danger")
|
flash("A review thread already exists!", "danger")
|
||||||
return redirect(package.review_thread.getViewURL())
|
return redirect(package.review_thread.getViewURL())
|
||||||
|
|
||||||
elif not current_user.canOpenThreadRL():
|
elif not current_user.canOpenThreadRL():
|
||||||
flash(gettext("Please wait before opening another thread"), "danger")
|
flash("Please wait before opening another thread", "danger")
|
||||||
|
|
||||||
if package:
|
if package:
|
||||||
return redirect(package.getURL("packages.view"))
|
return redirect(package.getDetailsURL())
|
||||||
else:
|
else:
|
||||||
return redirect(url_for("homepage.home"))
|
return redirect(url_for("homepage.home"))
|
||||||
|
|
||||||
|
@ -320,7 +276,7 @@ def new():
|
||||||
form.title.data = request.args.get("title") or ""
|
form.title.data = request.args.get("title") or ""
|
||||||
|
|
||||||
# Validate and submit
|
# Validate and submit
|
||||||
elif form.validate_on_submit():
|
elif request.method == "POST" and form.validate():
|
||||||
thread = Thread()
|
thread = Thread()
|
||||||
thread.author = current_user
|
thread.author = current_user
|
||||||
thread.title = form.title.data
|
thread.title = form.title.data
|
||||||
|
@ -345,26 +301,12 @@ def new():
|
||||||
if is_review_thread:
|
if is_review_thread:
|
||||||
package.review_thread = thread
|
package.review_thread = thread
|
||||||
|
|
||||||
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
|
|
||||||
mentioned = User.query.filter_by(username=mentioned_username)
|
|
||||||
if mentioned is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
|
|
||||||
addNotification(mentioned, current_user, NotificationType.NEW_THREAD,
|
|
||||||
msg, thread.getViewURL(), thread.package)
|
|
||||||
|
|
||||||
notif_msg = "New thread '{}'".format(thread.title)
|
notif_msg = "New thread '{}'".format(thread.title)
|
||||||
if package is not None:
|
if package is not None:
|
||||||
addNotification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.getViewURL(), package)
|
addNotification(package.maintainers, current_user, notif_msg, thread.getViewURL(), package)
|
||||||
|
|
||||||
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
|
editors = User.query.filter(User.rank >= UserRank.EDITOR).all()
|
||||||
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
|
addNotification(editors, current_user, notif_msg, thread.getViewURL(), package)
|
||||||
|
|
||||||
|
|
||||||
if is_review_thread:
|
|
||||||
post_discord_webhook.delay(current_user.username,
|
|
||||||
"Opened approval thread: {}".format(thread.getViewURL(absolute=True)), True)
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@ -372,12 +314,3 @@ def new():
|
||||||
|
|
||||||
|
|
||||||
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)
|
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/comments/")
|
|
||||||
def user_comments(username):
|
|
||||||
user = User.query.filter_by(username=username).first()
|
|
||||||
if user is None:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
return render_template("threads/user_comments.html", user=user, replies=user.replies)
|
|
|
@ -1,17 +1,17 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,45 +1,38 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from celery import uuid
|
|
||||||
from flask import *
|
from flask import *
|
||||||
from flask_login import current_user, login_required
|
from flask_user import *
|
||||||
from sqlalchemy import or_, and_
|
import flask_menu as menu
|
||||||
|
|
||||||
from app.models import *
|
from app.models import *
|
||||||
from app.querybuilder import QueryBuilder
|
from app.querybuilder import QueryBuilder
|
||||||
from app.utils import get_int_or_abort, addNotification, addAuditLog, isYes
|
from app.utils import get_int_or_abort
|
||||||
from app.tasks.importtasks import makeVCSRelease
|
|
||||||
|
|
||||||
bp = Blueprint("todo", __name__)
|
bp = Blueprint("todo", __name__)
|
||||||
|
|
||||||
@bp.route("/todo/", methods=["GET", "POST"])
|
@bp.route("/todo/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def view_editor():
|
def view():
|
||||||
canApproveNew = Permission.APPROVE_NEW.check(current_user)
|
canApproveNew = Permission.APPROVE_NEW.check(current_user)
|
||||||
canApproveRel = Permission.APPROVE_RELEASE.check(current_user)
|
canApproveRel = Permission.APPROVE_RELEASE.check(current_user)
|
||||||
canApproveScn = Permission.APPROVE_SCREENSHOT.check(current_user)
|
canApproveScn = Permission.APPROVE_SCREENSHOT.check(current_user)
|
||||||
|
|
||||||
packages = None
|
packages = None
|
||||||
wip_packages = None
|
|
||||||
if canApproveNew:
|
if canApproveNew:
|
||||||
packages = Package.query.filter_by(state=PackageState.READY_FOR_REVIEW) \
|
packages = Package.query.filter_by(approved=False, soft_deleted=False).order_by(db.desc(Package.created_at)).all()
|
||||||
.order_by(db.desc(Package.created_at)).all()
|
|
||||||
wip_packages = Package.query.filter(or_(Package.state==PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \
|
|
||||||
.order_by(db.desc(Package.created_at)).all()
|
|
||||||
|
|
||||||
releases = None
|
releases = None
|
||||||
if canApproveRel:
|
if canApproveRel:
|
||||||
|
@ -59,29 +52,22 @@ def view_editor():
|
||||||
|
|
||||||
PackageScreenshot.query.update({ "approved": True })
|
PackageScreenshot.query.update({ "approved": True })
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(url_for("todo.view_editor"))
|
return redirect(url_for("todo.view"))
|
||||||
else:
|
else:
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
license_needed = Package.query \
|
topic_query = ForumTopic.query \
|
||||||
.filter(Package.state.in_([PackageState.READY_FOR_REVIEW, PackageState.APPROVED])) \
|
.filter_by(discarded=False)
|
||||||
.filter(or_(Package.license.has(License.name.like("Other %")),
|
|
||||||
Package.media_license.has(License.name.like("Other %")))) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
total_packages = Package.query.filter_by(state=PackageState.APPROVED).count()
|
total_topics = topic_query.count()
|
||||||
total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count()
|
topics_to_add = topic_query \
|
||||||
|
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
|
||||||
|
.count()
|
||||||
|
|
||||||
unfulfilled_meta_packages = MetaPackage.query \
|
return render_template("todo/list.html", title="Reports and Work Queue",
|
||||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
packages=packages, releases=releases, screenshots=screenshots,
|
||||||
.filter(MetaPackage.dependencies.any(Package.state == PackageState.APPROVED, optional=False)) \
|
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
|
||||||
.order_by(db.asc(MetaPackage.name)).count()
|
topics_to_add=topics_to_add, total_topics=total_topics)
|
||||||
|
|
||||||
return render_template("todo/editor.html", current_tab="editor",
|
|
||||||
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
|
|
||||||
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
|
|
||||||
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
|
|
||||||
unfulfilled_meta_packages=unfulfilled_meta_packages)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/todo/topics/")
|
@bp.route("/todo/topics/")
|
||||||
|
@ -99,167 +85,27 @@ def topics():
|
||||||
|
|
||||||
page = get_int_or_abort(request.args.get("page"), 1)
|
page = get_int_or_abort(request.args.get("page"), 1)
|
||||||
num = get_int_or_abort(request.args.get("n"), 100)
|
num = get_int_or_abort(request.args.get("n"), 100)
|
||||||
if num > 100 and not current_user.rank.atLeast(UserRank.APPROVER):
|
if num > 100 and not current_user.rank.atLeast(UserRank.EDITOR):
|
||||||
num = 100
|
num = 100
|
||||||
|
|
||||||
query = query.paginate(page, num, True)
|
query = query.paginate(page, num, True)
|
||||||
next_url = url_for("todo.topics", page=query.next_num, query=qb.search,
|
next_url = url_for("todo.topics", page=query.next_num, query=qb.search, \
|
||||||
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
||||||
if query.has_next else None
|
if query.has_next else None
|
||||||
prev_url = url_for("todo.topics", page=query.prev_num, query=qb.search,
|
prev_url = url_for("todo.topics", page=query.prev_num, query=qb.search, \
|
||||||
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
|
||||||
if query.has_prev else None
|
if query.has_prev else None
|
||||||
|
|
||||||
return render_template("todo/topics.html", current_tab="topics", topics=query.items, total=total,
|
return render_template("todo/topics.html", topics=query.items, total=total, \
|
||||||
topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded,
|
topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded, \
|
||||||
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages,
|
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages, \
|
||||||
n=num, sort_by=qb.order_by)
|
n=num, sort_by=qb.order_by)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/todo/tags/")
|
@bp.route("/todo/tags/")
|
||||||
@login_required
|
@login_required
|
||||||
def tags():
|
def tags():
|
||||||
qb = QueryBuilder(request.args)
|
packages = Package.query.filter_by(approved=True, soft_deleted=False).all()
|
||||||
qb.setSortIfNone("score", "desc")
|
|
||||||
query = qb.buildPackageQuery()
|
|
||||||
|
|
||||||
only_no_tags = isYes(request.args.get("no_tags"))
|
|
||||||
if only_no_tags:
|
|
||||||
query = query.filter(Package.tags==None)
|
|
||||||
|
|
||||||
tags = Tag.query.order_by(db.asc(Tag.title)).all()
|
tags = Tag.query.order_by(db.asc(Tag.title)).all()
|
||||||
|
|
||||||
return render_template("todo/tags.html", current_tab="tags", packages=query.all(), \
|
return render_template("todo/tags.html", packages=packages, tags=tags)
|
||||||
tags=tags, only_no_tags=only_no_tags)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/tags/")
|
|
||||||
def tags_user():
|
|
||||||
return redirect(url_for('todo.tags', author=current_user.username))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/todo/metapackages/")
|
|
||||||
@login_required
|
|
||||||
def metapackages():
|
|
||||||
mpackages = MetaPackage.query \
|
|
||||||
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
|
|
||||||
.filter(MetaPackage.dependencies.any(optional=False)) \
|
|
||||||
.order_by(db.asc(MetaPackage.name)).all()
|
|
||||||
|
|
||||||
return render_template("todo/metapackages.html", mpackages=mpackages)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/todo/")
|
|
||||||
@bp.route("/users/<username>/todo/")
|
|
||||||
@login_required
|
|
||||||
def view_user(username=None):
|
|
||||||
if username is None:
|
|
||||||
return redirect(url_for("todo.view_user", username=current_user.username))
|
|
||||||
|
|
||||||
user : User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if current_user != user and not current_user.rank.atLeast(UserRank.APPROVER):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
unapproved_packages = user.packages \
|
|
||||||
.filter(or_(Package.state == PackageState.WIP,
|
|
||||||
Package.state == PackageState.CHANGES_NEEDED)) \
|
|
||||||
.order_by(db.asc(Package.created_at)).all()
|
|
||||||
|
|
||||||
packages_with_small_screenshots = user.maintained_packages \
|
|
||||||
.filter(Package.screenshots.any(and_(PackageScreenshot.width < PackageScreenshot.SOFT_MIN_SIZE[0],
|
|
||||||
PackageScreenshot.height < PackageScreenshot.SOFT_MIN_SIZE[1]))) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
outdated_packages = user.maintained_packages \
|
|
||||||
.filter(Package.state != PackageState.DELETED,
|
|
||||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
|
||||||
.order_by(db.asc(Package.title)).all()
|
|
||||||
|
|
||||||
topics_to_add = ForumTopic.query \
|
|
||||||
.filter_by(author_id=user.id) \
|
|
||||||
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
|
|
||||||
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
needs_tags = user.maintained_packages \
|
|
||||||
.filter(Package.state != PackageState.DELETED, Package.tags==None) \
|
|
||||||
.order_by(db.asc(Package.title)).all()
|
|
||||||
|
|
||||||
return render_template("todo/user.html", current_tab="user", user=user,
|
|
||||||
unapproved_packages=unapproved_packages, outdated_packages=outdated_packages,
|
|
||||||
needs_tags=needs_tags, topics_to_add=topics_to_add,
|
|
||||||
packages_with_small_screenshots=packages_with_small_screenshots,
|
|
||||||
screenshot_min_size=PackageScreenshot.HARD_MIN_SIZE, screenshot_rec_size=PackageScreenshot.SOFT_MIN_SIZE)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])
|
|
||||||
@login_required
|
|
||||||
def apply_all_updates(username):
|
|
||||||
user: User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
outdated_packages = user.maintained_packages \
|
|
||||||
.filter(Package.state != PackageState.DELETED,
|
|
||||||
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
|
|
||||||
.order_by(db.asc(Package.title)).all()
|
|
||||||
|
|
||||||
for package in outdated_packages:
|
|
||||||
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if package.releases.filter(or_(PackageRelease.task_id.isnot(None),
|
|
||||||
PackageRelease.commit_hash==package.update_config.last_commit)).count() > 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
title = package.update_config.get_title()
|
|
||||||
ref = package.update_config.get_ref()
|
|
||||||
|
|
||||||
rel = PackageRelease()
|
|
||||||
rel.package = package
|
|
||||||
rel.title = title
|
|
||||||
rel.url = ""
|
|
||||||
rel.task_id = uuid()
|
|
||||||
db.session.add(rel)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
makeVCSRelease.apply_async((rel.id, ref),
|
|
||||||
task_id=rel.task_id)
|
|
||||||
|
|
||||||
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
|
|
||||||
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
|
|
||||||
rel.getURL("packages.create_edit"), package)
|
|
||||||
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getURL("packages.view"), package)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for("todo.view_user", username=username))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/todo/outdated/")
|
|
||||||
@login_required
|
|
||||||
def outdated():
|
|
||||||
is_mtm_only = isYes(request.args.get("mtm"))
|
|
||||||
|
|
||||||
query = db.session.query(Package).select_from(PackageUpdateConfig) \
|
|
||||||
.filter(PackageUpdateConfig.outdated_at.isnot(None)) \
|
|
||||||
.join(PackageUpdateConfig.package) \
|
|
||||||
.filter(Package.state == PackageState.APPROVED)
|
|
||||||
|
|
||||||
if is_mtm_only:
|
|
||||||
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
|
|
||||||
|
|
||||||
sort_by = request.args.get("sort")
|
|
||||||
if sort_by == "date":
|
|
||||||
query = query.order_by(db.desc(PackageUpdateConfig.outdated_at))
|
|
||||||
else:
|
|
||||||
sort_by = "score"
|
|
||||||
query = query.order_by(db.desc(Package.score))
|
|
||||||
|
|
||||||
return render_template("todo/outdated.html", current_tab="outdated",
|
|
||||||
outdated_packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only)
|
|
||||||
|
|
|
@ -2,4 +2,4 @@ from flask import Blueprint
|
||||||
|
|
||||||
bp = Blueprint("users", __name__)
|
bp = Blueprint("users", __name__)
|
||||||
|
|
||||||
from . import profile, claim, account, settings
|
from . import profile, claim
|
||||||
|
|
|
@ -1,427 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2020 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from flask import *
|
|
||||||
from flask_babel import gettext, lazy_gettext, get_locale
|
|
||||||
from flask_login import current_user, login_required, logout_user, login_user
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from sqlalchemy import or_
|
|
||||||
from wtforms import *
|
|
||||||
from wtforms.validators import *
|
|
||||||
|
|
||||||
from app.models import *
|
|
||||||
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
|
|
||||||
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \
|
|
||||||
nonEmptyOrNone, post_login, is_username_valid
|
|
||||||
from passlib.pwd import genphrase
|
|
||||||
|
|
||||||
from . import bp
|
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(FlaskForm):
|
|
||||||
username = StringField(lazy_gettext("Username or email"), [InputRequired()])
|
|
||||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
|
|
||||||
remember_me = BooleanField(lazy_gettext("Remember me"), default=True)
|
|
||||||
submit = SubmitField(lazy_gettext("Sign in"))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_login(form):
|
|
||||||
def show_safe_err(err):
|
|
||||||
if "@" in username:
|
|
||||||
flash(gettext("Incorrect email or password"), "danger")
|
|
||||||
else:
|
|
||||||
flash(err, "danger")
|
|
||||||
|
|
||||||
|
|
||||||
username = form.username.data.strip()
|
|
||||||
user = User.query.filter(or_(User.username == username, User.email == username)).first()
|
|
||||||
if user is None:
|
|
||||||
return show_safe_err(gettext(u"User %(username)s does not exist", username=username))
|
|
||||||
|
|
||||||
if not check_password_hash(user.password, form.password.data):
|
|
||||||
return show_safe_err(gettext(u"Incorrect password. Did you set one?"))
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
flash(gettext("You need to confirm the registration email"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
addAuditLog(AuditSeverity.USER, user, "Logged in using password",
|
|
||||||
url_for("users.profile", username=user.username))
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if not login_user(user, remember=form.remember_me.data):
|
|
||||||
flash(gettext("Login failed"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
return post_login(user, request.args.get("next"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/login/", methods=["GET", "POST"])
|
|
||||||
def login():
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
next = request.args.get("next")
|
|
||||||
if next and not is_safe_url(next):
|
|
||||||
abort(400)
|
|
||||||
|
|
||||||
return redirect(next or url_for("homepage.home"))
|
|
||||||
|
|
||||||
form = LoginForm(request.form)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
ret = handle_login(form)
|
|
||||||
if ret:
|
|
||||||
return ret
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
form.remember_me.data = True
|
|
||||||
|
|
||||||
|
|
||||||
return render_template("users/login.html", form=form)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/logout/", methods=["GET", "POST"])
|
|
||||||
def logout():
|
|
||||||
logout_user()
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterForm(FlaskForm):
|
|
||||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonEmptyOrNone])
|
|
||||||
username = StringField(lazy_gettext("Username"), [InputRequired(),
|
|
||||||
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext("Only a-zA-Z0-9._ allowed"))])
|
|
||||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
|
||||||
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
|
|
||||||
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
|
|
||||||
agree = BooleanField(lazy_gettext("I agree"), [DataRequired()])
|
|
||||||
submit = SubmitField(lazy_gettext("Register"))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_register(form):
|
|
||||||
if form.question.data.strip().lower() != "19":
|
|
||||||
flash(gettext("Incorrect captcha answer"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not is_username_valid(form.username.data):
|
|
||||||
flash(gettext("Username is invalid"))
|
|
||||||
return
|
|
||||||
|
|
||||||
user_by_name = User.query.filter(or_(
|
|
||||||
User.username == form.username.data,
|
|
||||||
User.username == form.display_name.data,
|
|
||||||
User.display_name == form.display_name.data,
|
|
||||||
User.forums_username == form.username.data,
|
|
||||||
User.github_username == form.username.data)).first()
|
|
||||||
if user_by_name:
|
|
||||||
if user_by_name.rank == UserRank.NOT_JOINED and user_by_name.forums_username:
|
|
||||||
flash(gettext("An account already exists for that username but hasn't been claimed yet."), "danger")
|
|
||||||
return redirect(url_for("users.claim_forums", username=user_by_name.forums_username))
|
|
||||||
else:
|
|
||||||
flash(gettext("That username/display name is already in use, please choose another."), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
alias_by_name = PackageAlias.query.filter(or_(
|
|
||||||
PackageAlias.author==form.username.data,
|
|
||||||
PackageAlias.author==form.display_name.data)).first()
|
|
||||||
if alias_by_name:
|
|
||||||
flash(gettext("That username/display name is already in use, please choose another."), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
user_by_email = User.query.filter_by(email=form.email.data).first()
|
|
||||||
if user_by_email:
|
|
||||||
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
|
|
||||||
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
|
|
||||||
display_name=user_by_email.display_name))
|
|
||||||
return redirect(url_for("users.email_sent"))
|
|
||||||
elif EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
|
||||||
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data))
|
|
||||||
user.notification_preferences = UserNotificationPreferences(user)
|
|
||||||
if form.display_name.data:
|
|
||||||
user.display_name = form.display_name.data
|
|
||||||
db.session.add(user)
|
|
||||||
|
|
||||||
addAuditLog(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name,
|
|
||||||
url_for("users.profile", username=user.username))
|
|
||||||
|
|
||||||
token = randomString(32)
|
|
||||||
|
|
||||||
ver = UserEmailVerification()
|
|
||||||
ver.user = user
|
|
||||||
ver.token = token
|
|
||||||
ver.email = form.email.data
|
|
||||||
db.session.add(ver)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
|
||||||
|
|
||||||
return redirect(url_for("users.email_sent"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/register/", methods=["GET", "POST"])
|
|
||||||
def register():
|
|
||||||
form = RegisterForm(request.form)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
ret = handle_register(form)
|
|
||||||
if ret:
|
|
||||||
return ret
|
|
||||||
|
|
||||||
return render_template("users/register.html", form=form,
|
|
||||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
|
||||||
|
|
||||||
|
|
||||||
class ForgotPasswordForm(FlaskForm):
|
|
||||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
|
||||||
submit = SubmitField(lazy_gettext("Reset Password"))
|
|
||||||
|
|
||||||
@bp.route("/user/forgot-password/", methods=["GET", "POST"])
|
|
||||||
def forgot_password():
|
|
||||||
form = ForgotPasswordForm(request.form)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
email = form.email.data
|
|
||||||
user = User.query.filter_by(email=email).first()
|
|
||||||
if user:
|
|
||||||
token = randomString(32)
|
|
||||||
|
|
||||||
addAuditLog(AuditSeverity.USER, user, "(Anonymous) requested a password reset",
|
|
||||||
url_for("users.profile", username=user.username), None)
|
|
||||||
|
|
||||||
ver = UserEmailVerification()
|
|
||||||
ver.user = user
|
|
||||||
ver.token = token
|
|
||||||
ver.email = email
|
|
||||||
ver.is_password_reset = True
|
|
||||||
db.session.add(ver)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
|
||||||
else:
|
|
||||||
html = render_template("emails/unable_to_find_account.html")
|
|
||||||
send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"),
|
|
||||||
html, html)
|
|
||||||
|
|
||||||
return redirect(url_for("users.email_sent"))
|
|
||||||
|
|
||||||
return render_template("users/forgot_password.html", form=form)
|
|
||||||
|
|
||||||
|
|
||||||
class SetPasswordForm(FlaskForm):
|
|
||||||
email = StringField(lazy_gettext("Email"), [Optional(), Email()])
|
|
||||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
|
|
||||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
|
|
||||||
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
|
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
|
||||||
|
|
||||||
class ChangePasswordForm(FlaskForm):
|
|
||||||
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(8, 100)])
|
|
||||||
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
|
|
||||||
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
|
|
||||||
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
|
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_set_password(form):
|
|
||||||
one = form.password.data
|
|
||||||
two = form.password2.data
|
|
||||||
if one != two:
|
|
||||||
flash(gettext("Passwords do not match"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
addAuditLog(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
|
|
||||||
|
|
||||||
current_user.password = make_flask_login_password(form.password.data)
|
|
||||||
|
|
||||||
if hasattr(form, "email"):
|
|
||||||
newEmail = nonEmptyOrNone(form.email.data)
|
|
||||||
if newEmail and newEmail != current_user.email:
|
|
||||||
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
|
||||||
flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
user_by_email = User.query.filter_by(email=form.email.data).first()
|
|
||||||
if user_by_email:
|
|
||||||
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
|
|
||||||
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
|
|
||||||
display_name=user_by_email.display_name))
|
|
||||||
else:
|
|
||||||
token = randomString(32)
|
|
||||||
|
|
||||||
ver = UserEmailVerification()
|
|
||||||
ver.user = current_user
|
|
||||||
ver.token = token
|
|
||||||
ver.email = newEmail
|
|
||||||
db.session.add(ver)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
send_verify_email.delay(form.email.data, token, get_locale().language)
|
|
||||||
|
|
||||||
flash(gettext("Your password has been changed successfully."), "success")
|
|
||||||
return redirect(url_for("users.email_sent"))
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
flash(gettext("Your password has been changed successfully."), "success")
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/change-password/", methods=["GET", "POST"])
|
|
||||||
@login_required
|
|
||||||
def change_password():
|
|
||||||
form = ChangePasswordForm(request.form)
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
if check_password_hash(current_user.password, form.old_password.data):
|
|
||||||
ret = handle_set_password(form)
|
|
||||||
if ret:
|
|
||||||
return ret
|
|
||||||
else:
|
|
||||||
flash(gettext("Old password is incorrect"), "danger")
|
|
||||||
|
|
||||||
return render_template("users/change_set_password.html", form=form,
|
|
||||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/set-password/", methods=["GET", "POST"])
|
|
||||||
@login_required
|
|
||||||
def set_password():
|
|
||||||
if current_user.password:
|
|
||||||
return redirect(url_for("users.change_password"))
|
|
||||||
|
|
||||||
form = SetPasswordForm(request.form)
|
|
||||||
if current_user.email is None:
|
|
||||||
form.email.validators = [InputRequired(), Email()]
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
ret = handle_set_password(form)
|
|
||||||
if ret:
|
|
||||||
return ret
|
|
||||||
|
|
||||||
return render_template("users/change_set_password.html", form=form, optional=request.args.get("optional"),
|
|
||||||
suggested_password=genphrase(entropy=52, wordset="bip39"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/verify/")
|
|
||||||
def verify_email():
|
|
||||||
token = request.args.get("token")
|
|
||||||
ver: UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
|
|
||||||
if ver is None:
|
|
||||||
flash(gettext("Unknown verification token!"), "danger")
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
delta = (datetime.datetime.now() - ver.created_at)
|
|
||||||
delta: datetime.timedelta
|
|
||||||
if delta.total_seconds() > 12*60*60:
|
|
||||||
flash(gettext("Token has expired"), "danger")
|
|
||||||
db.session.delete(ver)
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
user = ver.user
|
|
||||||
|
|
||||||
addAuditLog(AuditSeverity.USER, user, "Confirmed their email",
|
|
||||||
url_for("users.profile", username=user.username))
|
|
||||||
|
|
||||||
was_activating = not user.is_active
|
|
||||||
|
|
||||||
if ver.email and user.email != ver.email:
|
|
||||||
if User.query.filter_by(email=ver.email).count() > 0:
|
|
||||||
flash(gettext("Another user is already using that email"), "danger")
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
flash(gettext("Confirmed email change"), "success")
|
|
||||||
|
|
||||||
if user.email:
|
|
||||||
send_user_email.delay(user.email,
|
|
||||||
user.locale or "en",
|
|
||||||
gettext("Email address changed"),
|
|
||||||
gettext("Your email address has changed. If you didn't request this, please contact an administrator."))
|
|
||||||
|
|
||||||
user.is_active = True
|
|
||||||
user.email = ver.email
|
|
||||||
|
|
||||||
db.session.delete(ver)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if ver.is_password_reset:
|
|
||||||
login_user(user)
|
|
||||||
user.password = None
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for("users.set_password"))
|
|
||||||
|
|
||||||
if current_user.is_authenticated:
|
|
||||||
return redirect(url_for("users.profile", username=current_user.username))
|
|
||||||
elif was_activating:
|
|
||||||
flash(gettext("You may now log in"), "success")
|
|
||||||
return redirect(url_for("users.login"))
|
|
||||||
else:
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
|
|
||||||
class UnsubscribeForm(FlaskForm):
|
|
||||||
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
|
|
||||||
submit = SubmitField(lazy_gettext("Send"))
|
|
||||||
|
|
||||||
|
|
||||||
def unsubscribe_verify():
|
|
||||||
form = UnsubscribeForm(request.form)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
email = form.email.data
|
|
||||||
sub = EmailSubscription.query.filter_by(email=email).first()
|
|
||||||
if not sub:
|
|
||||||
sub = EmailSubscription(email)
|
|
||||||
db.session.add(sub)
|
|
||||||
|
|
||||||
sub.token = randomString(32)
|
|
||||||
db.session.commit()
|
|
||||||
send_unsubscribe_verify.delay(form.email.data, get_locale().language)
|
|
||||||
|
|
||||||
return redirect(url_for("users.email_sent"))
|
|
||||||
|
|
||||||
return render_template("users/unsubscribe.html", form=form)
|
|
||||||
|
|
||||||
|
|
||||||
def unsubscribe_manage(sub: EmailSubscription):
|
|
||||||
user = User.query.filter_by(email=sub.email).first()
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
if user:
|
|
||||||
user.email = None
|
|
||||||
|
|
||||||
sub.blacklisted = True
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(gettext("That email is now blacklisted. Please contact an admin if you wish to undo this."), "success")
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
return render_template("users/unsubscribe.html", user=user)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/unsubscribe/", methods=["GET", "POST"])
|
|
||||||
def unsubscribe():
|
|
||||||
token = request.args.get("token")
|
|
||||||
if token:
|
|
||||||
sub = EmailSubscription.query.filter_by(token=token).first()
|
|
||||||
if sub:
|
|
||||||
return unsubscribe_manage(sub)
|
|
||||||
|
|
||||||
return unsubscribe_verify()
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/email_sent/")
|
|
||||||
def email_sent():
|
|
||||||
return render_template("users/email_sent.html")
|
|
|
@ -1,76 +1,72 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from flask_babel import gettext
|
|
||||||
|
|
||||||
from . import bp
|
from . import bp
|
||||||
from flask import redirect, render_template, session, request, flash, url_for
|
from flask import redirect, render_template, session, request, flash, url_for
|
||||||
|
from flask_user import current_user
|
||||||
from app.models import db, User, UserRank
|
from app.models import db, User, UserRank
|
||||||
from app.utils import randomString, login_user_set_active, is_username_valid
|
from app.utils import randomString, loginUser, rank_required
|
||||||
from app.tasks.forumtasks import checkForumAccount
|
from app.tasks.forumtasks import checkForumAccount
|
||||||
from app.utils.phpbbparser import getProfile
|
from app.tasks.phpbbparser import getProfile
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/claim/", methods=["GET", "POST"])
|
@bp.route("/user/claim/", methods=["GET", "POST"])
|
||||||
def claim():
|
def claim():
|
||||||
return render_template("users/claim.html")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/claim-forums/", methods=["GET", "POST"])
|
|
||||||
def claim_forums():
|
|
||||||
username = request.args.get("username")
|
username = request.args.get("username")
|
||||||
if username is None:
|
if username is None:
|
||||||
username = ""
|
username = ""
|
||||||
else:
|
else:
|
||||||
method = request.args.get("method")
|
method = request.args.get("method")
|
||||||
|
|
||||||
if not is_username_valid(username):
|
|
||||||
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
|
|
||||||
return redirect(url_for("users.claim_forums"))
|
|
||||||
|
|
||||||
user = User.query.filter_by(forums_username=username).first()
|
user = User.query.filter_by(forums_username=username).first()
|
||||||
if user and user.rank.atLeast(UserRank.NEW_MEMBER):
|
if user and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||||
flash(gettext("User has already been claimed"), "danger")
|
flash("User has already been claimed", "danger")
|
||||||
return redirect(url_for("users.claim_forums"))
|
return redirect(url_for("users.claim"))
|
||||||
elif method == "github":
|
elif method == "github":
|
||||||
if user is None or user.github_username is None:
|
if user is None or user.github_username is None:
|
||||||
flash(gettext("Unable to get GitHub username for user"), "danger")
|
flash("Unable to get Github username for user", "danger")
|
||||||
return redirect(url_for("users.claim_forums", username=username))
|
return redirect(url_for("users.claim", username=username))
|
||||||
else:
|
else:
|
||||||
return redirect(url_for("github.start"))
|
return redirect(url_for("github.start"))
|
||||||
|
elif user is None and request.method == "POST":
|
||||||
|
flash("Unable to find user", "danger")
|
||||||
|
return redirect(url_for("users.claim"))
|
||||||
|
|
||||||
|
|
||||||
|
token = None
|
||||||
if "forum_token" in session:
|
if "forum_token" in session:
|
||||||
token = session["forum_token"]
|
token = session["forum_token"]
|
||||||
else:
|
else:
|
||||||
token = randomString(12)
|
token = randomString(32)
|
||||||
session["forum_token"] = token
|
session["forum_token"] = token
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
ctype = request.form.get("claim_type")
|
ctype = request.form.get("claim_type")
|
||||||
username = request.form.get("username")
|
username = request.form.get("username")
|
||||||
|
|
||||||
if not is_username_valid(username):
|
if username is None or len(username.strip()) < 2:
|
||||||
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
|
flash("Invalid username", "danger")
|
||||||
elif ctype == "github":
|
elif ctype == "github":
|
||||||
task = checkForumAccount.delay(username)
|
task = checkForumAccount.delay(username)
|
||||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github")))
|
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim", username=username, method="github")))
|
||||||
elif ctype == "forum":
|
elif ctype == "forum":
|
||||||
user = User.query.filter_by(forums_username=username).first()
|
user = User.query.filter_by(forums_username=username).first()
|
||||||
if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
|
if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||||
flash(gettext("That user has already been claimed!"), "danger")
|
flash("That user has already been claimed!", "danger")
|
||||||
return redirect(url_for("users.claim_forums"))
|
return redirect(url_for("users.claim"))
|
||||||
|
|
||||||
# Get signature
|
# Get signature
|
||||||
sig = None
|
sig = None
|
||||||
|
@ -83,34 +79,31 @@ def claim_forums():
|
||||||
else:
|
else:
|
||||||
message = str(e)
|
message = str(e)
|
||||||
|
|
||||||
flash(gettext(u"Error whilst attempting to access forums: %(message)s", message=message), "danger")
|
flash("Error whilst attempting to access forums: " + message, "danger")
|
||||||
return redirect(url_for("users.claim_forums", username=username))
|
return redirect(url_for("users.claim", username=username))
|
||||||
|
|
||||||
if profile is None:
|
if profile is None:
|
||||||
flash(gettext("Unable to get forum signature - does the user exist?"), "danger")
|
flash("Unable to get forum signature - does the user exist?", "danger")
|
||||||
return redirect(url_for("users.claim_forums", username=username))
|
return redirect(url_for("users.claim", username=username))
|
||||||
|
|
||||||
# Look for key
|
# Look for key
|
||||||
if sig and token in sig:
|
if token in sig:
|
||||||
# Try getting again to fix crash
|
|
||||||
user = User.query.filter_by(forums_username=username).first()
|
|
||||||
if user is None:
|
if user is None:
|
||||||
user = User(username)
|
user = User(username)
|
||||||
user.forums_username = username
|
user.forums_username = username
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
ret = login_user_set_active(user, remember=True)
|
if loginUser(user):
|
||||||
if ret is None:
|
return redirect(url_for("users.set_password"))
|
||||||
flash(gettext("Unable to login as user"), "danger")
|
else:
|
||||||
return redirect(url_for("users.claim_forums", username=username))
|
flash("Unable to login as user", "danger")
|
||||||
|
return redirect(url_for("users.claim", username=username))
|
||||||
return ret
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
flash(gettext("Could not find the key in your signature!"), "danger")
|
flash("Could not find the key in your signature!", "danger")
|
||||||
return redirect(url_for("users.claim_forums", username=username))
|
return redirect(url_for("users.claim", username=username))
|
||||||
else:
|
else:
|
||||||
flash(gettext("Unknown claim type"), "danger")
|
flash("Unknown claim type", "danger")
|
||||||
|
|
||||||
return render_template("users/claim_forums.html", username=username, key="cdb_" + token)
|
return render_template("users/claim.html", username=username, key=token)
|
||||||
|
|
|
@ -1,30 +1,45 @@
|
||||||
# ContentDB
|
# ContentDB
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
# Copyright (C) 2018 rubenwardy
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
#
|
#
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU Affero General Public License for more details.
|
# GNU General Public License for more details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import math
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from flask import *
|
from flask import *
|
||||||
from flask_babel import gettext
|
from flask_user import signals, current_user, user_manager
|
||||||
from flask_login import current_user, login_required
|
from flask_login import login_user, logout_user
|
||||||
|
from app.markdown import render_markdown
|
||||||
|
from . import bp
|
||||||
|
from app.models import *
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import *
|
||||||
|
from wtforms.validators import *
|
||||||
|
from app.utils import randomString, loginUser, rank_required, nonEmptyOrNone, addAuditLog
|
||||||
|
from app.tasks.forumtasks import checkForumAccount
|
||||||
|
from app.tasks.emails import sendVerifyEmail, sendEmailRaw
|
||||||
|
from app.tasks.phpbbparser import getProfile
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from app.models import *
|
# Define the User profile form
|
||||||
from app.tasks.forumtasks import checkForumAccount
|
class UserProfileForm(FlaskForm):
|
||||||
from . import bp
|
display_name = StringField("Display name", [Optional(), Length(2, 100)])
|
||||||
|
forums_username = StringField("Forums Username", [Optional(), Length(2, 50)])
|
||||||
|
github_username = StringField("GitHub Username", [Optional(), Length(2, 50)])
|
||||||
|
email = StringField("Email", [Optional(), Email()], filters = [lambda x: x or None])
|
||||||
|
website_url = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||||
|
donate_url = StringField("Donation URL", [Optional(), URL()], filters = [lambda x: x or None])
|
||||||
|
rank = SelectField("Rank", [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce, default=UserRank.NEW_MEMBER)
|
||||||
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/", methods=["GET"])
|
@bp.route("/users/", methods=["GET"])
|
||||||
|
@ -37,199 +52,85 @@ def list_all():
|
||||||
return render_template("users/list.html", users=users)
|
return render_template("users/list.html", users=users)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/forum/<username>/")
|
@bp.route("/users/<username>/", methods=["GET", "POST"])
|
||||||
def by_forums_username(username):
|
|
||||||
user = User.query.filter_by(forums_username=username).first()
|
|
||||||
if user:
|
|
||||||
return redirect(url_for("users.profile", username=user.username))
|
|
||||||
|
|
||||||
return render_template("users/forums_no_such_user.html", username=username)
|
|
||||||
|
|
||||||
|
|
||||||
class Medal:
|
|
||||||
description: str
|
|
||||||
color: Optional[str]
|
|
||||||
icon: str
|
|
||||||
title: Optional[str]
|
|
||||||
progress: Optional[Tuple[int, int]]
|
|
||||||
|
|
||||||
def __init__(self, description: str, **kwargs):
|
|
||||||
self.description = description
|
|
||||||
self.color = kwargs.get("color", "white")
|
|
||||||
self.icon = kwargs.get("icon", None)
|
|
||||||
self.title = kwargs.get("title", None)
|
|
||||||
self.progress = kwargs.get("progress", None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def make_unlocked(cls, color: str, icon: str, title: str, description: str):
|
|
||||||
return Medal(description=description, color=color, icon=icon, title=title)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def make_locked(cls, description: str, progress: Tuple[int, int]):
|
|
||||||
return Medal(description=description, progress=progress)
|
|
||||||
|
|
||||||
|
|
||||||
def place_to_color(place: int) -> str:
|
|
||||||
if place == 1:
|
|
||||||
return "gold"
|
|
||||||
elif place == 2:
|
|
||||||
return "#888"
|
|
||||||
elif place == 3:
|
|
||||||
return "#cd7f32"
|
|
||||||
else:
|
|
||||||
return "white"
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
|
|
||||||
unlocked = []
|
|
||||||
locked = []
|
|
||||||
|
|
||||||
#
|
|
||||||
# REVIEWS
|
|
||||||
#
|
|
||||||
|
|
||||||
users_by_reviews = db.session.query(User.username, func.sum(PackageReview.score).label("karma")) \
|
|
||||||
.select_from(User).join(PackageReview) \
|
|
||||||
.group_by(User.username).order_by(text("karma DESC")).all()
|
|
||||||
try:
|
|
||||||
review_boundary = users_by_reviews[math.floor(len(users_by_reviews) * 0.25)][1] + 1
|
|
||||||
except IndexError:
|
|
||||||
review_boundary = None
|
|
||||||
usernames_by_reviews = [username for username, _ in users_by_reviews]
|
|
||||||
|
|
||||||
review_idx = None
|
|
||||||
review_percent = None
|
|
||||||
review_karma = 0
|
|
||||||
try:
|
|
||||||
review_idx = usernames_by_reviews.index(user.username)
|
|
||||||
review_percent = round(100 * review_idx / len(users_by_reviews), 1)
|
|
||||||
review_karma = max(users_by_reviews[review_idx][1], 0)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if review_percent is not None and review_percent < 25:
|
|
||||||
if review_idx == 0:
|
|
||||||
title = gettext(u"Top reviewer")
|
|
||||||
description = gettext(
|
|
||||||
u"%(display_name)s has written the most helpful reviews on ContentDB.",
|
|
||||||
display_name=user.display_name)
|
|
||||||
elif review_idx <= 2:
|
|
||||||
if review_idx == 1:
|
|
||||||
title = gettext(u"2nd most helpful reviewer")
|
|
||||||
else:
|
|
||||||
title = gettext(u"3rd most helpful reviewer")
|
|
||||||
description = gettext(
|
|
||||||
u"This puts %(display_name)s in the top %(perc)s%%",
|
|
||||||
display_name=user.display_name, perc=review_percent)
|
|
||||||
else:
|
|
||||||
title = gettext(u"Top %(perc)s%% reviewer", perc=review_percent)
|
|
||||||
description = gettext(u"Only %(place)d users have written more helpful reviews.", place=review_idx)
|
|
||||||
|
|
||||||
unlocked.append(Medal.make_unlocked(
|
|
||||||
place_to_color(review_idx + 1), "fa-star-half-alt", title, description))
|
|
||||||
else:
|
|
||||||
description = gettext(u"Consider writing more helpful reviews to get a medal.")
|
|
||||||
if review_idx:
|
|
||||||
description += " " + gettext(u"You are in place %(place)s.", place=review_idx + 1)
|
|
||||||
locked.append(Medal.make_locked(
|
|
||||||
description, (review_karma, review_boundary)))
|
|
||||||
|
|
||||||
#
|
|
||||||
# TOP PACKAGES
|
|
||||||
#
|
|
||||||
all_package_ranks = db.session.query(
|
|
||||||
Package.type,
|
|
||||||
Package.author_id,
|
|
||||||
func.rank().over(
|
|
||||||
order_by=db.desc(Package.score),
|
|
||||||
partition_by=Package.type) \
|
|
||||||
.label("rank")).order_by(db.asc(text("rank"))) \
|
|
||||||
.filter_by(state=PackageState.APPROVED).subquery()
|
|
||||||
|
|
||||||
user_package_ranks = db.session.query(all_package_ranks) \
|
|
||||||
.filter_by(author_id=user.id) \
|
|
||||||
.filter(text("rank <= 30")) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
user_package_ranks = next(
|
|
||||||
(x for x in user_package_ranks if x[0] == PackageType.MOD or x[2] <= 10),
|
|
||||||
None)
|
|
||||||
if user_package_ranks:
|
|
||||||
top_rank = user_package_ranks[2]
|
|
||||||
top_type = PackageType.coerce(user_package_ranks[0])
|
|
||||||
if top_rank == 1:
|
|
||||||
title = gettext(u"Top %(type)s", type=top_type.text.lower())
|
|
||||||
else:
|
|
||||||
title = gettext(u"Top %(group)d %(type)s", group=top_rank, type=top_type.text.lower())
|
|
||||||
if top_type == PackageType.MOD:
|
|
||||||
icon = "fa-box"
|
|
||||||
elif top_type == PackageType.GAME:
|
|
||||||
icon = "fa-gamepad"
|
|
||||||
else:
|
|
||||||
icon = "fa-paint-brush"
|
|
||||||
|
|
||||||
description = gettext(u"%(display_name)s has a %(type)s placed at #%(place)d.",
|
|
||||||
display_name=user.display_name, type=top_type.text.lower(), place=top_rank)
|
|
||||||
unlocked.append(
|
|
||||||
Medal.make_unlocked(place_to_color(top_rank), icon, title, description))
|
|
||||||
|
|
||||||
#
|
|
||||||
# DOWNLOADS
|
|
||||||
#
|
|
||||||
total_downloads = db.session.query(func.sum(Package.downloads)) \
|
|
||||||
.select_from(User) \
|
|
||||||
.join(User.packages) \
|
|
||||||
.filter(User.id == user.id,
|
|
||||||
Package.state == PackageState.APPROVED).scalar()
|
|
||||||
if total_downloads is None:
|
|
||||||
pass
|
|
||||||
elif total_downloads < 50000:
|
|
||||||
description = gettext(u"Your packages have %(downloads)d downloads in total.", downloads=total_downloads)
|
|
||||||
description += " " + gettext(u"First medal is at 50k.")
|
|
||||||
locked.append(Medal.make_locked(description, (total_downloads, 50000)))
|
|
||||||
else:
|
|
||||||
if total_downloads >= 300000:
|
|
||||||
place = 1
|
|
||||||
title = gettext(u">300k downloads")
|
|
||||||
elif total_downloads >= 100000:
|
|
||||||
place = 2
|
|
||||||
title = gettext(u">100k downloads")
|
|
||||||
elif total_downloads >= 75000:
|
|
||||||
place = 3
|
|
||||||
title = gettext(u">75k downloads")
|
|
||||||
else:
|
|
||||||
place = 10
|
|
||||||
title = gettext(u">50k downloads")
|
|
||||||
description = gettext(u"Has received %(downloads)d downloads across all packages.",
|
|
||||||
display_name=user.display_name, downloads=total_downloads)
|
|
||||||
unlocked.append(Medal.make_unlocked(place_to_color(place), "fa-users", title, description))
|
|
||||||
|
|
||||||
return unlocked, locked
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/")
|
|
||||||
def profile(username):
|
def profile(username):
|
||||||
user = User.query.filter_by(username=username).first()
|
user = User.query.filter_by(username=username).first()
|
||||||
if not user:
|
if not user:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
form = None
|
||||||
|
if user.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
|
||||||
|
user.checkPerm(current_user, Permission.CHANGE_EMAIL) or \
|
||||||
|
user.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||||
|
# Initialize form
|
||||||
|
form = UserProfileForm(formdata=request.form, obj=user)
|
||||||
|
|
||||||
|
# Process valid POST
|
||||||
|
if request.method=="POST" and form.validate():
|
||||||
|
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
||||||
|
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
|
||||||
|
url_for("users.profile", username=username))
|
||||||
|
|
||||||
|
# Copy form fields to user_profile fields
|
||||||
|
if user.checkPerm(current_user, Permission.CHANGE_USERNAMES):
|
||||||
|
user.display_name = form.display_name.data
|
||||||
|
user.forums_username = nonEmptyOrNone(form.forums_username.data)
|
||||||
|
user.github_username = nonEmptyOrNone(form.github_username.data)
|
||||||
|
|
||||||
|
if user.checkPerm(current_user, Permission.CHANGE_PROFILE_URLS):
|
||||||
|
user.website_url = form["website_url"].data
|
||||||
|
user.donate_url = form["donate_url"].data
|
||||||
|
|
||||||
|
if user.checkPerm(current_user, Permission.CHANGE_RANK):
|
||||||
|
newRank = form["rank"].data
|
||||||
|
if current_user.rank.atLeast(newRank):
|
||||||
|
if newRank != user.rank:
|
||||||
|
user.rank = form["rank"].data
|
||||||
|
msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle())
|
||||||
|
addAuditLog(AuditSeverity.MODERATION, current_user, msg, url_for("users.profile", username=username))
|
||||||
|
else:
|
||||||
|
flash("Can't promote a user to a rank higher than yourself!", "danger")
|
||||||
|
|
||||||
|
if user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
||||||
|
newEmail = form["email"].data
|
||||||
|
if newEmail != user.email and newEmail.strip() != "":
|
||||||
|
token = randomString(32)
|
||||||
|
|
||||||
|
msg = "Changed email of {}".format(user.display_name)
|
||||||
|
addAuditLog(severity, current_user, msg, url_for("users.profile", username=username))
|
||||||
|
|
||||||
|
ver = UserEmailVerification()
|
||||||
|
ver.user = user
|
||||||
|
ver.token = token
|
||||||
|
ver.email = newEmail
|
||||||
|
db.session.add(ver)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
task = sendVerifyEmail.delay(newEmail, token)
|
||||||
|
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.profile", username=username)))
|
||||||
|
|
||||||
|
# Save user_profile
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Redirect to home page
|
||||||
|
return redirect(url_for("users.profile", username=username))
|
||||||
|
|
||||||
|
packages = user.packages.filter_by(soft_deleted=False)
|
||||||
if not current_user.is_authenticated or (user != current_user and not current_user.canAccessTodoList()):
|
if not current_user.is_authenticated or (user != current_user and not current_user.canAccessTodoList()):
|
||||||
packages = user.packages.filter_by(state=PackageState.APPROVED)
|
packages = packages.filter_by(approved=True)
|
||||||
maintained_packages = user.maintained_packages.filter_by(state=PackageState.APPROVED)
|
packages = packages.order_by(db.asc(Package.title))
|
||||||
else:
|
|
||||||
packages = user.packages.filter(Package.state != PackageState.DELETED)
|
|
||||||
maintained_packages = user.maintained_packages.filter(Package.state != PackageState.DELETED)
|
|
||||||
|
|
||||||
packages = packages.order_by(db.asc(Package.title)).all()
|
topics_to_add = None
|
||||||
maintained_packages = maintained_packages \
|
if current_user == user or user.checkPerm(current_user, Permission.CHANGE_AUTHOR):
|
||||||
.filter(Package.author != user) \
|
topics_to_add = ForumTopic.query \
|
||||||
.order_by(db.asc(Package.title)).all()
|
.filter_by(author_id=user.id) \
|
||||||
|
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
|
||||||
|
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
|
||||||
|
.all()
|
||||||
|
|
||||||
unlocked, locked = get_user_medals(user)
|
|
||||||
# Process GET or invalid POST
|
# Process GET or invalid POST
|
||||||
return render_template("users/profile.html", user=user,
|
return render_template("users/profile.html",
|
||||||
packages=packages, maintained_packages=maintained_packages,
|
user=user, form=form, packages=packages, topics_to_add=topics_to_add)
|
||||||
medals_unlocked=unlocked, medals_locked=locked)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/check/", methods=["POST"])
|
@bp.route("/users/<username>/check/", methods=["POST"])
|
||||||
|
@ -249,3 +150,111 @@ def user_check(username):
|
||||||
next_url = url_for("users.profile", username=username)
|
next_url = url_for("users.profile", username=username)
|
||||||
|
|
||||||
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
||||||
|
|
||||||
|
|
||||||
|
class SendEmailForm(FlaskForm):
|
||||||
|
subject = StringField("Subject", [InputRequired(), Length(1, 300)])
|
||||||
|
text = TextAreaField("Message", [InputRequired()])
|
||||||
|
submit = SubmitField("Send")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/users/<username>/email/", methods=["GET", "POST"])
|
||||||
|
@rank_required(UserRank.MODERATOR)
|
||||||
|
def send_email(username):
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if user is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
next_url = url_for("users.profile", username=user.username)
|
||||||
|
|
||||||
|
if user.email is None:
|
||||||
|
flash("User has no email address!", "danger")
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
form = SendEmailForm(request.form)
|
||||||
|
if form.validate_on_submit():
|
||||||
|
addAuditLog(AuditSeverity.MODERATION, current_user,
|
||||||
|
"Sent email to {}".format(user.display_name), url_for("users.profile", username=username))
|
||||||
|
|
||||||
|
text = form.text.data
|
||||||
|
html = render_markdown(text)
|
||||||
|
task = sendEmailRaw.delay([user.email], form.subject.data, text, html)
|
||||||
|
return redirect(url_for("tasks.check", id=task.id, r=next_url))
|
||||||
|
|
||||||
|
return render_template("users/send_email.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordForm(FlaskForm):
|
||||||
|
email = StringField("Email", [Optional(), Email()])
|
||||||
|
password = PasswordField("New password", [InputRequired(), Length(2, 100)])
|
||||||
|
password2 = PasswordField("Verify password", [InputRequired(), Length(2, 100)])
|
||||||
|
submit = SubmitField("Save")
|
||||||
|
|
||||||
|
@bp.route("/user/set-password/", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def set_password():
|
||||||
|
if current_user.hasPassword():
|
||||||
|
return redirect(url_for("user.change_password"))
|
||||||
|
|
||||||
|
form = SetPasswordForm(request.form)
|
||||||
|
if current_user.email == None:
|
||||||
|
form.email.validators = [InputRequired(), Email()]
|
||||||
|
|
||||||
|
if request.method == "POST" and form.validate():
|
||||||
|
one = form.password.data
|
||||||
|
two = form.password2.data
|
||||||
|
if one == two:
|
||||||
|
# Hash password
|
||||||
|
hashed_password = user_manager.hash_password(form.password.data)
|
||||||
|
|
||||||
|
# Change password
|
||||||
|
current_user.password = hashed_password
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Send 'password_changed' email
|
||||||
|
if user_manager.USER_ENABLE_EMAIL and current_user.email:
|
||||||
|
user_manager.email_manager.send_password_changed_email(current_user)
|
||||||
|
|
||||||
|
# Send password_changed signal
|
||||||
|
signals.user_changed_password.send(current_app._get_current_object(), user=current_user)
|
||||||
|
|
||||||
|
# Prepare one-time system message
|
||||||
|
flash('Your password has been changed successfully.', 'success')
|
||||||
|
|
||||||
|
newEmail = form["email"].data
|
||||||
|
if newEmail != current_user.email and newEmail.strip() != "":
|
||||||
|
token = randomString(32)
|
||||||
|
|
||||||
|
ver = UserEmailVerification()
|
||||||
|
ver.user = current_user
|
||||||
|
ver.token = token
|
||||||
|
ver.email = newEmail
|
||||||
|
db.session.add(ver)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
task = sendVerifyEmail.delay(newEmail, token)
|
||||||
|
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.profile", username=current_user.username)))
|
||||||
|
else:
|
||||||
|
return redirect(url_for("user.login"))
|
||||||
|
else:
|
||||||
|
flash("Passwords do not match", "danger")
|
||||||
|
|
||||||
|
return render_template("users/set_password.html", form=form, optional=request.args.get("optional"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/users/verify/")
|
||||||
|
def verify_email():
|
||||||
|
token = request.args.get("token")
|
||||||
|
ver = UserEmailVerification.query.filter_by(token=token).first()
|
||||||
|
if ver is None:
|
||||||
|
flash("Unknown verification token!", "danger")
|
||||||
|
else:
|
||||||
|
ver.user.email = ver.email
|
||||||
|
db.session.delete(ver)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("users.profile", username=current_user.username))
|
||||||
|
else:
|
||||||
|
return redirect(url_for("homepage.home"))
|
||||||
|
|
|
@ -1,368 +0,0 @@
|
||||||
from flask import *
|
|
||||||
from flask_babel import gettext, lazy_gettext, get_locale
|
|
||||||
from flask_login import current_user, login_required, logout_user
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from sqlalchemy import or_
|
|
||||||
from wtforms import *
|
|
||||||
from wtforms.validators import *
|
|
||||||
|
|
||||||
from app.models import *
|
|
||||||
from app.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required
|
|
||||||
from app.tasks.emails import send_verify_email
|
|
||||||
from . import bp
|
|
||||||
|
|
||||||
|
|
||||||
def get_setting_tabs(user):
|
|
||||||
ret = [
|
|
||||||
{
|
|
||||||
"id": "edit_profile",
|
|
||||||
"title": gettext("Edit Profile"),
|
|
||||||
"url": url_for("users.profile_edit", username=user.username)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "account",
|
|
||||||
"title": gettext("Account and Security"),
|
|
||||||
"url": url_for("users.account", username=user.username)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "notifications",
|
|
||||||
"title": gettext("Email and Notifications"),
|
|
||||||
"url": url_for("users.email_notifications", username=user.username)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "api_tokens",
|
|
||||||
"title": gettext("API Tokens"),
|
|
||||||
"url": url_for("api.list_tokens", username=user.username)
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if current_user.rank.atLeast(UserRank.MODERATOR):
|
|
||||||
ret.append({
|
|
||||||
"id": "modtools",
|
|
||||||
"title": gettext("Moderator Tools"),
|
|
||||||
"url": url_for("users.modtools", username=user.username)
|
|
||||||
})
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(FlaskForm):
|
|
||||||
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[lambda x: nonEmptyOrNone(x)])
|
|
||||||
website_url = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
|
||||||
donate_url = StringField(lazy_gettext("Donation URL"), [Optional(), URL()], filters = [lambda x: x or None])
|
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
|
||||||
|
|
||||||
|
|
||||||
def handle_profile_edit(form, user, username):
|
|
||||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
|
||||||
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
|
|
||||||
url_for("users.profile", username=username))
|
|
||||||
|
|
||||||
if user.checkPerm(current_user, Permission.CHANGE_DISPLAY_NAME) and \
|
|
||||||
user.display_name != form.display_name.data:
|
|
||||||
if User.query.filter(User.id != user.id,
|
|
||||||
or_(User.username == form.display_name.data,
|
|
||||||
User.display_name.ilike(form.display_name.data))).count() > 0:
|
|
||||||
flash(gettext("A user already has that name"), "danger")
|
|
||||||
return None
|
|
||||||
|
|
||||||
alias_by_name = PackageAlias.query.filter(or_(
|
|
||||||
PackageAlias.author == form.display_name.data)).first()
|
|
||||||
if alias_by_name:
|
|
||||||
flash(gettext("A user already has that name"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
user.display_name = form.display_name.data
|
|
||||||
|
|
||||||
severity = AuditSeverity.USER if current_user == user else AuditSeverity.MODERATION
|
|
||||||
addAuditLog(severity, current_user, "Changed display name of {} to {}"
|
|
||||||
.format(user.username, user.display_name),
|
|
||||||
url_for("users.profile", username=username))
|
|
||||||
|
|
||||||
if user.checkPerm(current_user, Permission.CHANGE_PROFILE_URLS):
|
|
||||||
user.website_url = form["website_url"].data
|
|
||||||
user.donate_url = form["donate_url"].data
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for("users.profile", username=username))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/settings/profile/", methods=["GET", "POST"])
|
|
||||||
@login_required
|
|
||||||
def profile_edit(username):
|
|
||||||
user : User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if not user.can_see_edit_profile(current_user):
|
|
||||||
flash(gettext("Permission denied"), "danger")
|
|
||||||
return redirect(url_for("users.profile", username=username))
|
|
||||||
|
|
||||||
form = UserProfileForm(obj=user)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
ret = handle_profile_edit(form, user, username)
|
|
||||||
if ret:
|
|
||||||
return ret
|
|
||||||
|
|
||||||
# Process GET or invalid POST
|
|
||||||
return render_template("users/profile_edit.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="edit_profile")
|
|
||||||
|
|
||||||
|
|
||||||
def make_settings_form():
|
|
||||||
attrs = {
|
|
||||||
"email": StringField(lazy_gettext("Email"), [Optional(), Email()]),
|
|
||||||
"submit": SubmitField(lazy_gettext("Save"))
|
|
||||||
}
|
|
||||||
|
|
||||||
for notificationType in NotificationType:
|
|
||||||
key = "pref_" + notificationType.toName()
|
|
||||||
attrs[key] = BooleanField("")
|
|
||||||
attrs[key + "_digest"] = BooleanField("")
|
|
||||||
|
|
||||||
return type("SettingsForm", (FlaskForm,), attrs)
|
|
||||||
|
|
||||||
SettingsForm = make_settings_form()
|
|
||||||
|
|
||||||
|
|
||||||
def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new, form):
|
|
||||||
for notificationType in NotificationType:
|
|
||||||
field_email = getattr(form, "pref_" + notificationType.toName()).data
|
|
||||||
field_digest = getattr(form, "pref_" + notificationType.toName() + "_digest").data or field_email
|
|
||||||
prefs.set_can_email(notificationType, field_email)
|
|
||||||
prefs.set_can_digest(notificationType, field_digest)
|
|
||||||
|
|
||||||
if is_new:
|
|
||||||
db.session.add(prefs)
|
|
||||||
|
|
||||||
if user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
|
||||||
newEmail = form.email.data
|
|
||||||
if newEmail and newEmail != user.email and newEmail.strip() != "":
|
|
||||||
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
|
|
||||||
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
|
|
||||||
return
|
|
||||||
|
|
||||||
token = randomString(32)
|
|
||||||
|
|
||||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
|
||||||
|
|
||||||
msg = "Changed email of {}".format(user.display_name)
|
|
||||||
addAuditLog(severity, current_user, msg, url_for("users.profile", username=user.username))
|
|
||||||
|
|
||||||
ver = UserEmailVerification()
|
|
||||||
ver.user = user
|
|
||||||
ver.token = token
|
|
||||||
ver.email = newEmail
|
|
||||||
db.session.add(ver)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
send_verify_email.delay(newEmail, token, get_locale().language)
|
|
||||||
return redirect(url_for("users.email_sent"))
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for("users.email_notifications", username=user.username))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/user/settings/email/")
|
|
||||||
@bp.route("/users/<username>/settings/email/", methods=["GET", "POST"])
|
|
||||||
@login_required
|
|
||||||
def email_notifications(username=None):
|
|
||||||
if username is None:
|
|
||||||
return redirect(url_for("users.email_notifications", username=current_user.username))
|
|
||||||
|
|
||||||
user: User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
is_new = False
|
|
||||||
prefs = user.notification_preferences
|
|
||||||
if prefs is None:
|
|
||||||
is_new = True
|
|
||||||
prefs = UserNotificationPreferences(user)
|
|
||||||
|
|
||||||
data = {}
|
|
||||||
types = []
|
|
||||||
for notificationType in NotificationType:
|
|
||||||
types.append(notificationType)
|
|
||||||
data["pref_" + notificationType.toName()] = prefs.get_can_email(notificationType)
|
|
||||||
data["pref_" + notificationType.toName() + "_digest"] = prefs.get_can_digest(notificationType)
|
|
||||||
|
|
||||||
data["email"] = user.email
|
|
||||||
|
|
||||||
form = SettingsForm(data=data)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
ret = handle_email_notifications(user, prefs, is_new, form)
|
|
||||||
if ret:
|
|
||||||
return ret
|
|
||||||
|
|
||||||
return render_template("users/settings_email.html",
|
|
||||||
form=form, user=user, types=types, is_new=is_new,
|
|
||||||
tabs=get_setting_tabs(user), current_tab="notifications")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/settings/account/")
|
|
||||||
@login_required
|
|
||||||
def account(username):
|
|
||||||
user : User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
return render_template("users/account.html", user=user, tabs=get_setting_tabs(user), current_tab="account")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/delete/", methods=["GET", "POST"])
|
|
||||||
@rank_required(UserRank.ADMIN)
|
|
||||||
def delete(username):
|
|
||||||
user: User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if user.rank.atLeast(UserRank.MODERATOR):
|
|
||||||
flash(gettext("Users with moderator rank or above cannot be deleted"), "danger")
|
|
||||||
return redirect(url_for("users.account", username=username))
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
return render_template("users/delete.html", user=user, can_delete=user.can_delete())
|
|
||||||
|
|
||||||
if "delete" in request.form and (user.can_delete() or current_user.rank.atLeast(UserRank.ADMIN)):
|
|
||||||
msg = "Deleted user {}".format(user.username)
|
|
||||||
flash(msg, "success")
|
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
|
|
||||||
|
|
||||||
if current_user.rank.atLeast(UserRank.ADMIN):
|
|
||||||
for pkg in user.packages.all():
|
|
||||||
pkg.review_thread = None
|
|
||||||
db.session.delete(pkg)
|
|
||||||
|
|
||||||
db.session.delete(user)
|
|
||||||
elif "deactivate" in request.form:
|
|
||||||
user.replies.delete()
|
|
||||||
for thread in user.threads.all():
|
|
||||||
db.session.delete(thread)
|
|
||||||
user.email = None
|
|
||||||
user.rank = UserRank.NOT_JOINED
|
|
||||||
|
|
||||||
msg = "Deactivated user {}".format(user.username)
|
|
||||||
flash(msg, "success")
|
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
|
|
||||||
else:
|
|
||||||
assert False
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if user == current_user:
|
|
||||||
logout_user()
|
|
||||||
|
|
||||||
return redirect(url_for("homepage.home"))
|
|
||||||
|
|
||||||
|
|
||||||
class ModToolsForm(FlaskForm):
|
|
||||||
username = StringField(lazy_gettext("Username"), [Optional(), Length(1, 50)])
|
|
||||||
display_name = StringField(lazy_gettext("Display name"), [Optional(), Length(2, 100)])
|
|
||||||
forums_username = StringField(lazy_gettext("Forums Username"), [Optional(), Length(2, 50)])
|
|
||||||
github_username = StringField(lazy_gettext("GitHub Username"), [Optional(), Length(2, 50)])
|
|
||||||
rank = SelectField(lazy_gettext("Rank"), [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce,
|
|
||||||
default=UserRank.NEW_MEMBER)
|
|
||||||
submit = SubmitField(lazy_gettext("Save"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/modtools/", methods=["GET", "POST"])
|
|
||||||
@rank_required(UserRank.MODERATOR)
|
|
||||||
def modtools(username):
|
|
||||||
user: User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
form = ModToolsForm(obj=user)
|
|
||||||
if form.validate_on_submit():
|
|
||||||
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
|
|
||||||
addAuditLog(severity, current_user, "Edited {}'s account".format(user.display_name),
|
|
||||||
url_for("users.profile", username=username))
|
|
||||||
|
|
||||||
# Copy form fields to user_profile fields
|
|
||||||
if user.checkPerm(current_user, Permission.CHANGE_USERNAMES):
|
|
||||||
if user.username != form.username.data:
|
|
||||||
for package in user.packages:
|
|
||||||
alias = PackageAlias(user.username, package.name)
|
|
||||||
package.aliases.append(alias)
|
|
||||||
db.session.add(alias)
|
|
||||||
|
|
||||||
user.username = form.username.data
|
|
||||||
|
|
||||||
user.display_name = form.display_name.data
|
|
||||||
user.forums_username = nonEmptyOrNone(form.forums_username.data)
|
|
||||||
user.github_username = nonEmptyOrNone(form.github_username.data)
|
|
||||||
|
|
||||||
if user.checkPerm(current_user, Permission.CHANGE_RANK):
|
|
||||||
newRank = form["rank"].data
|
|
||||||
if current_user.rank.atLeast(newRank):
|
|
||||||
if newRank != user.rank:
|
|
||||||
user.rank = form["rank"].data
|
|
||||||
msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle())
|
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user, msg,
|
|
||||||
url_for("users.profile", username=username))
|
|
||||||
else:
|
|
||||||
flash(gettext("Can't promote a user to a rank higher than yourself!"), "danger")
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for("users.modtools", username=username))
|
|
||||||
|
|
||||||
return render_template("users/modtools.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="modtools")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/modtools/set-email/", methods=["POST"])
|
|
||||||
@rank_required(UserRank.MODERATOR)
|
|
||||||
def modtools_set_email(username):
|
|
||||||
user: User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
user.email = request.form["email"]
|
|
||||||
user.is_active = False
|
|
||||||
|
|
||||||
token = randomString(32)
|
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user, f"Set email and sent a password reset on {user.username}",
|
|
||||||
url_for("users.profile", username=user.username), None)
|
|
||||||
|
|
||||||
ver = UserEmailVerification()
|
|
||||||
ver.user = user
|
|
||||||
ver.token = token
|
|
||||||
ver.email = user.email
|
|
||||||
ver.is_password_reset = True
|
|
||||||
db.session.add(ver)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
send_verify_email.delay(user.email, token, user.locale or "en")
|
|
||||||
|
|
||||||
flash(f"Set email and sent a password reset on {user.username}", "success")
|
|
||||||
return redirect(url_for("users.modtools", username=username))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/users/<username>/modtools/ban/", methods=["POST"])
|
|
||||||
@rank_required(UserRank.MODERATOR)
|
|
||||||
def modtools_ban(username):
|
|
||||||
user: User = User.query.filter_by(username=username).first()
|
|
||||||
if not user:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if not user.checkPerm(current_user, Permission.CHANGE_RANK):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
user.rank = UserRank.BANNED
|
|
||||||
|
|
||||||
addAuditLog(AuditSeverity.MODERATION, current_user, f"Banned {user.username}",
|
|
||||||
url_for("users.profile", username=user.username), None)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(f"Banned {user.username}", "success")
|
|
||||||
return redirect(url_for("users.modtools", username=username))
|
|
|
@ -1,33 +1,26 @@
|
||||||
from .models import *
|
from .models import *
|
||||||
from .utils import make_flask_login_password
|
from .utils import make_flask_user_password
|
||||||
|
|
||||||
|
|
||||||
def populate(session):
|
def populate(session):
|
||||||
admin_user = User("rubenwardy")
|
admin_user = User("rubenwardy")
|
||||||
admin_user.is_active = True
|
admin_user.active = True
|
||||||
admin_user.password = make_flask_login_password("tuckfrump")
|
admin_user.password = make_flask_user_password("tuckfrump")
|
||||||
admin_user.github_username = "rubenwardy"
|
admin_user.github_username = "rubenwardy"
|
||||||
admin_user.forums_username = "rubenwardy"
|
admin_user.forums_username = "rubenwardy"
|
||||||
admin_user.rank = UserRank.ADMIN
|
admin_user.rank = UserRank.ADMIN
|
||||||
session.add(admin_user)
|
session.add(admin_user)
|
||||||
|
|
||||||
system_user = User("ContentDB", active=False)
|
|
||||||
system_user.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
|
|
||||||
system_user.rank = UserRank.BOT
|
|
||||||
session.add(system_user)
|
|
||||||
|
|
||||||
session.add(MinetestRelease("None", 0))
|
session.add(MinetestRelease("None", 0))
|
||||||
session.add(MinetestRelease("0.4.16/17", 32))
|
session.add(MinetestRelease("0.4.16/17", 32))
|
||||||
session.add(MinetestRelease("5.0", 37))
|
session.add(MinetestRelease("5.0", 37))
|
||||||
session.add(MinetestRelease("5.1", 38))
|
session.add(MinetestRelease("5.1", 38))
|
||||||
session.add(MinetestRelease("5.2", 39))
|
|
||||||
session.add(MinetestRelease("5.3", 39))
|
|
||||||
|
|
||||||
tags = {}
|
tags = {}
|
||||||
for tag in ["Inventory", "Mapgen", "Building",
|
for tag in ["Inventory", "Mapgen", "Building", \
|
||||||
"Mobs and NPCs", "Tools", "Player effects",
|
"Mobs and NPCs", "Tools", "Player effects", \
|
||||||
"Environment", "Transport", "Maintenance", "Plants and farming",
|
"Environment", "Transport", "Maintenance", "Plants and farming", \
|
||||||
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer", "Featured"]:
|
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
|
||||||
row = Tag(tag)
|
row = Tag(tag)
|
||||||
tags[row.name] = row
|
tags[row.name] = row
|
||||||
session.add(row)
|
session.add(row)
|
||||||
|
@ -60,7 +53,7 @@ def populate_test_data(session):
|
||||||
ez.rank = UserRank.EDITOR
|
ez.rank = UserRank.EDITOR
|
||||||
session.add(ez)
|
session.add(ez)
|
||||||
|
|
||||||
not1 = Notification(admin_user, ez, NotificationType.PACKAGE_APPROVAL, "Awards approved", "/packages/rubenwardy/awards/")
|
not1 = Notification(admin_user, ez, "Awards approved", "/packages/rubenwardy/awards/")
|
||||||
session.add(not1)
|
session.add(not1)
|
||||||
|
|
||||||
jeija = User("Jeija")
|
jeija = User("Jeija")
|
||||||
|
@ -70,7 +63,7 @@ def populate_test_data(session):
|
||||||
|
|
||||||
|
|
||||||
mod = Package()
|
mod = Package()
|
||||||
mod.state = PackageState.APPROVED
|
mod.approved = True
|
||||||
mod.name = "alpha"
|
mod.name = "alpha"
|
||||||
mod.title = "Alpha Test"
|
mod.title = "Alpha Test"
|
||||||
mod.license = licenses["MIT"]
|
mod.license = licenses["MIT"]
|
||||||
|
@ -94,7 +87,7 @@ def populate_test_data(session):
|
||||||
session.add(rel)
|
session.add(rel)
|
||||||
|
|
||||||
mod1 = Package()
|
mod1 = Package()
|
||||||
mod1.state = PackageState.APPROVED
|
mod1.approved = True
|
||||||
mod1.name = "awards"
|
mod1.name = "awards"
|
||||||
mod1.title = "Awards"
|
mod1.title = "Awards"
|
||||||
mod1.license = licenses["LGPLv2.1"]
|
mod1.license = licenses["LGPLv2.1"]
|
||||||
|
@ -109,7 +102,7 @@ def populate_test_data(session):
|
||||||
mod1.desc = """
|
mod1.desc = """
|
||||||
Majority of awards are back ported from Calinou's old fork in Carbone, under same license.
|
Majority of awards are back ported from Calinou's old fork in Carbone, under same license.
|
||||||
|
|
||||||
```lua
|
```
|
||||||
awards.register_achievement("award_mesefind",{
|
awards.register_achievement("award_mesefind",{
|
||||||
title = "First Mese Find",
|
title = "First Mese Find",
|
||||||
description = "Found some Mese!",
|
description = "Found some Mese!",
|
||||||
|
@ -131,7 +124,7 @@ awards.register_achievement("award_mesefind",{
|
||||||
session.add(rel)
|
session.add(rel)
|
||||||
|
|
||||||
mod2 = Package()
|
mod2 = Package()
|
||||||
mod2.state = PackageState.APPROVED
|
mod2.approved = True
|
||||||
mod2.name = "mesecons"
|
mod2.name = "mesecons"
|
||||||
mod2.title = "Mesecons"
|
mod2.title = "Mesecons"
|
||||||
mod2.tags.append(tags["tools"])
|
mod2.tags.append(tags["tools"])
|
||||||
|
@ -220,7 +213,7 @@ No warranty is provided, express or implied, for any part of the project.
|
||||||
session.add(mod2)
|
session.add(mod2)
|
||||||
|
|
||||||
mod = Package()
|
mod = Package()
|
||||||
mod.state = PackageState.APPROVED
|
mod.approved = True
|
||||||
mod.name = "handholds"
|
mod.name = "handholds"
|
||||||
mod.title = "Handholds"
|
mod.title = "Handholds"
|
||||||
mod.license = licenses["MIT"]
|
mod.license = licenses["MIT"]
|
||||||
|
@ -244,7 +237,7 @@ No warranty is provided, express or implied, for any part of the project.
|
||||||
session.add(rel)
|
session.add(rel)
|
||||||
|
|
||||||
mod = Package()
|
mod = Package()
|
||||||
mod.state = PackageState.APPROVED
|
mod.approved = True
|
||||||
mod.name = "other_worlds"
|
mod.name = "other_worlds"
|
||||||
mod.title = "Other Worlds"
|
mod.title = "Other Worlds"
|
||||||
mod.license = licenses["MIT"]
|
mod.license = licenses["MIT"]
|
||||||
|
@ -261,7 +254,7 @@ No warranty is provided, express or implied, for any part of the project.
|
||||||
session.add(mod)
|
session.add(mod)
|
||||||
|
|
||||||
mod = Package()
|
mod = Package()
|
||||||
mod.state = PackageState.APPROVED
|
mod.approved = True
|
||||||
mod.name = "food"
|
mod.name = "food"
|
||||||
mod.title = "Food"
|
mod.title = "Food"
|
||||||
mod.license = licenses["LGPLv2.1"]
|
mod.license = licenses["LGPLv2.1"]
|
||||||
|
@ -277,7 +270,7 @@ No warranty is provided, express or implied, for any part of the project.
|
||||||
session.add(mod)
|
session.add(mod)
|
||||||
|
|
||||||
mod = Package()
|
mod = Package()
|
||||||
mod.state = PackageState.APPROVED
|
mod.approved = True
|
||||||
mod.name = "food_sweet"
|
mod.name = "food_sweet"
|
||||||
mod.title = "Sweet Foods"
|
mod.title = "Sweet Foods"
|
||||||
mod.license = licenses["CC0"]
|
mod.license = licenses["CC0"]
|
||||||
|
@ -294,7 +287,7 @@ No warranty is provided, express or implied, for any part of the project.
|
||||||
session.add(mod)
|
session.add(mod)
|
||||||
|
|
||||||
game1 = Package()
|
game1 = Package()
|
||||||
game1.state = PackageState.APPROVED
|
game1.approved = True
|
||||||
game1.name = "capturetheflag"
|
game1.name = "capturetheflag"
|
||||||
game1.title = "Capture The Flag"
|
game1.title = "Capture The Flag"
|
||||||
game1.type = PackageType.GAME
|
game1.type = PackageType.GAME
|
||||||
|
@ -357,7 +350,7 @@ Uses the CTF PvP Engine.
|
||||||
|
|
||||||
|
|
||||||
mod = Package()
|
mod = Package()
|
||||||
mod.state = PackageState.APPROVED
|
mod.approved = True
|
||||||
mod.name = "pixelbox"
|
mod.name = "pixelbox"
|
||||||
mod.title = "PixelBOX Reloaded"
|
mod.title = "PixelBOX Reloaded"
|
||||||
mod.license = licenses["CC0"]
|
mod.license = licenses["CC0"]
|
||||||
|
|
|
@ -1,30 +1,20 @@
|
||||||
title: Help
|
title: Help
|
||||||
toc: False
|
|
||||||
|
|
||||||
|
## For Everyone
|
||||||
|
|
||||||
## General Help
|
|
||||||
|
|
||||||
* [Frequently Asked Questions](faq)
|
|
||||||
* [Content Ratings and Flags](content_flags)
|
* [Content Ratings and Flags](content_flags)
|
||||||
* [Non-free Licenses](non_free)
|
* [Non-free Licenses](non_free)
|
||||||
* [Why WTFPL is a terrible license](wtfpl)
|
* [Package Tags](package_tags)
|
||||||
* [Ranks and Permissions](ranks_permissions)
|
* [Ranks and Permissions](ranks_permissions)
|
||||||
* [Contact Us](contact_us)
|
* [Reporting Content](reporting)
|
||||||
* [Top Packages Algorithm](top_packages)
|
* [Top Packages Algorithm](top_packages)
|
||||||
* [Featured Packages](featured)
|
|
||||||
|
|
||||||
## Help for Package Authors
|
## For Content Creators / Modders
|
||||||
|
|
||||||
* [Package Inclusion Policy and Guidance](/policy_and_guidance/)
|
|
||||||
* [Git Update Detection](update_config)
|
|
||||||
* [Creating Releases using Webhooks](release_webhooks)
|
* [Creating Releases using Webhooks](release_webhooks)
|
||||||
* [Package Configuration and Releases Guide](package_config)
|
* [Package Configuration and Releases Guide](package_config)
|
||||||
|
|
||||||
## Help for Specific User Ranks
|
## For Developers
|
||||||
|
|
||||||
* [Editors](editors)
|
|
||||||
|
|
||||||
## APIs
|
|
||||||
|
|
||||||
* [API](api)
|
* [API](api)
|
||||||
* [Prometheus Metrics](metrics)
|
* [Metrics](metrics)
|
||||||
|
|
|
@ -1,398 +1,88 @@
|
||||||
title: API
|
title: API
|
||||||
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
* [How the Minetest client uses the API](https://github.com/minetest/contentdb/blob/master/docs/minetest_client.md)
|
|
||||||
|
|
||||||
|
|
||||||
## Responses and Error Handling
|
|
||||||
|
|
||||||
If there is an error, the response will be JSON similar to the following with a non-200 status code:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"error": "The error message"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Successful GET requests will return the resource's information directly as a JSON response.
|
|
||||||
|
|
||||||
Other successful results will return a dictionary with `success` equaling true, and
|
|
||||||
often other keys with information. For example:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"release": {
|
|
||||||
/* same as returned by a GET */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Paginated Results
|
|
||||||
|
|
||||||
Some API endpoints returns results in pages. The page number is specified using the `page` query argument, and
|
|
||||||
the number of items is specified using `num`
|
|
||||||
|
|
||||||
The response will be a dictionary with the following keys:
|
|
||||||
|
|
||||||
* `page`: page number, integer from 1 to max
|
|
||||||
* `per_page`: number of items per page, same as `n`
|
|
||||||
* `page_count`: number of pages
|
|
||||||
* `total`: total number of results
|
|
||||||
* `urls`: dictionary containing
|
|
||||||
* `next`: url to next page
|
|
||||||
* `previous`: url to previous page
|
|
||||||
* `items`: array of items
|
|
||||||
|
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Not all endpoints require authentication, but it is done using Bearer tokens:
|
Not all endpoints require authentication.
|
||||||
|
Authentication is done using Bearer tokens:
|
||||||
|
|
||||||
```bash
|
Authorization: Bearer YOURTOKEN
|
||||||
curl https://content.minetest.net/api/whoami/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
|
You can use the `/api/whoami` to check authentication.
|
||||||
|
|
||||||
* GET `/api/whoami/`: JSON dictionary with the following keys:
|
Tokens can be attained by visiting [Profile > "API Tokens"](/user/tokens/).
|
||||||
* `is_authenticated`: True on successful API authentication
|
|
||||||
* `username`: Username of the user authenticated as, null otherwise.
|
|
||||||
* 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors.
|
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
## Packages
|
### Misc
|
||||||
|
|
||||||
* GET `/api/packages/` (List)
|
* GET `/api/whoami/` - Json dictionary with the following keys:
|
||||||
* See [Package Queries](#package-queries)
|
* `is_authenticated` - True on successful API authentication
|
||||||
* GET `/api/packages/<username>/<name>/` (Read)
|
* `username` - Username of the user authenticated as, null otherwise.
|
||||||
* PUT `/api/packages/<author>/<name>/` (Update)
|
* 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors.
|
||||||
* Requires authentication.
|
|
||||||
* JSON dictionary with any of these keys (all are optional, null to delete Nullables):
|
### Packages
|
||||||
* `type`: One of `GAME`, `MOD`, `TXP`.
|
|
||||||
* `title`: Human-readable title.
|
* GET `/api/packages/` - See [Package Queries](#package-queries)
|
||||||
* `name`: Technical name (needs permission if already approved).
|
* GET `/api/scores/` - See [Package Queries](#package-queries)
|
||||||
* `short_description`
|
* GET `/api/packages/<username>/<name>/`
|
||||||
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
|
|
||||||
`LOOKING_FOR_MAINTAINER`.
|
|
||||||
* `tags`: List of [tag](#tags) names.
|
|
||||||
* `content_warnings`: List of [content warning](#content-warnings) names.
|
|
||||||
* `license`: A [license](#licenses) name.
|
|
||||||
* `media_license`: A [license](#licenses) name.
|
|
||||||
* `long_description`: Long markdown description.
|
|
||||||
* `repo`: Git repo URL.
|
|
||||||
* `website`: Website URL.
|
|
||||||
* `issue_tracker`: Issue tracker URL.
|
|
||||||
* `forums`: forum topic ID.
|
|
||||||
* `video_url`: URL to a video.
|
|
||||||
* `game_support`: Array of game support information objects. Not currently documented, as subject to change.
|
|
||||||
* GET `/api/packages/<username>/<name>/dependencies/`
|
* GET `/api/packages/<username>/<name>/dependencies/`
|
||||||
* Returns dependencies, with suggested candidates
|
|
||||||
* If query argument `only_hard` is present, only hard deps will be returned.
|
* If query argument `only_hard` is present, only hard deps will be returned.
|
||||||
* GET `/api/dependencies/`
|
|
||||||
* Returns `provides` and raw dependencies for all packages.
|
|
||||||
* Supports [Package Queries](#package-queries)
|
|
||||||
* [Paginated result](#paginated-results), max 300 results per page
|
|
||||||
* Each item in `items` will be a dictionary with the following keys:
|
|
||||||
* `type`: One of `GAME`, `MOD`, `TXP`.
|
|
||||||
* `author`: Username of the package author.
|
|
||||||
* `name`: Package name.
|
|
||||||
* `provides`: List of technical mod names inside the package.
|
|
||||||
* `depends`: List of hard dependencies.
|
|
||||||
* Each dep will either be a metapackage dependency (`name`), or a
|
|
||||||
package dependency (`author/name`).
|
|
||||||
* `optional_depends`: list of optional dependencies
|
|
||||||
* Same as above.
|
|
||||||
|
|
||||||
You can download a package by building one of the two URLs:
|
### Releases
|
||||||
|
|
||||||
```
|
* GET `/api/packages/<username>/<name>/releases/`
|
||||||
https://content.minetest.net/packages/${author}/${name}/download/`
|
* POST `/api/packages/<username>/<name>/releases/new/`
|
||||||
https://content.minetest.net/packages/${author}/${name}/releases/${release}/download/`
|
* Requires authentication.
|
||||||
```
|
* `title`: human-readable name of the release.
|
||||||
|
* `method`: Release-creation method, only `git` is supported.
|
||||||
|
* If `git` release-creation method:
|
||||||
|
* `ref` - git reference, eg: `master`.
|
||||||
|
* You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/).
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```bash
|
### Topics
|
||||||
# Edit package
|
|
||||||
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }'
|
|
||||||
|
|
||||||
# Remove website URL
|
|
||||||
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d '{ "website": null }'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Package Queries
|
* GET `/api/topics/` - Supports [Package Queries](#package-queries), and the following two options:
|
||||||
|
* `show_added` - Show topics which exist as packages, default true.
|
||||||
|
* `show_discarded` - Show topics which have been marked as outdated, default false.
|
||||||
|
|
||||||
|
### Minetest
|
||||||
|
|
||||||
|
* GET `/api/minetest_versions/`
|
||||||
|
|
||||||
|
|
||||||
|
## Package Queries
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
/api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
|
/api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
|
||||||
|
|
||||||
Supported query parameters:
|
Supported query parameters:
|
||||||
|
|
||||||
* `type`: Package types (`mod`, `game`, `txp`).
|
* `type` - Package types (`mod`, `game`, `txp`).
|
||||||
* `q`: Query string.
|
* `q` - Query string.
|
||||||
* `author`: Filter by author.
|
* `author` - Filter by author.
|
||||||
* `tag`: Filter by tags.
|
* `tag` - Filter by tags.
|
||||||
* `random`: When present, enable random ordering and ignore `sort`.
|
* `random` - When present, enable random ordering and ignore `sort`.
|
||||||
* `limit`: Return at most `limit` packages.
|
* `limit` - Return at most `limit` packages.
|
||||||
* `hide`: Hide content based on [Content Flags](/help/content_flags/).
|
* `hide` - Hide content based on [Content Flags](/help/content_flags/).
|
||||||
* `sort`: Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`).
|
* `sort` - Sort by (`name`, `title`, `score`, `downloads`, `created_at`).
|
||||||
* `order`: Sort ascending (`asc`) or descending (`desc`).
|
* `order` - Sort ascending (`asc`) or descending (`desc`).
|
||||||
* `protocol_version`: Only show packages supported by this Minetest protocol version.
|
* `protocol_version` - Only show packages supported by this Minetest protocol version.
|
||||||
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
|
* `engine_version` - Only show packages supported by this Minetest engine version, eg: `5.3.0`.
|
||||||
* `fmt`: How the response is formated.
|
|
||||||
* `keys`: author/name only.
|
|
||||||
* `short`: stuff needed for the Minetest client.
|
|
||||||
|
|
||||||
|
|
||||||
## Releases
|
## Topic Queries
|
||||||
|
|
||||||
* GET `/api/releases/` (List)
|
|
||||||
* Limited to 30 most recent releases.
|
|
||||||
* Optional arguments:
|
|
||||||
* `author`: Filter by author
|
|
||||||
* `maintainer`: Filter by maintainer
|
|
||||||
* Returns array of release dictionaries with keys:
|
|
||||||
* `id`: release ID
|
|
||||||
* `title`: human-readable title
|
|
||||||
* `release_date`: Date released
|
|
||||||
* `url`: download URL
|
|
||||||
* `commit`: commit hash or null
|
|
||||||
* `downloads`: number of downloads
|
|
||||||
* `min_minetest_version`: dict or null, minimum supported minetest version (inclusive).
|
|
||||||
* `max_minetest_version`: dict or null, minimum supported minetest version (inclusive).
|
|
||||||
* `package`
|
|
||||||
* `author`: author username
|
|
||||||
* `name`: technical name
|
|
||||||
* `type`: `mod`, `game`, or `txp`
|
|
||||||
* GET `/api/packages/<username>/<name>/releases/` (List)
|
|
||||||
* Returns array of release dictionaries, see above, but without package info.
|
|
||||||
* GET `/api/packages/<username>/<name>/releases/<id>/` (Read)
|
|
||||||
* POST `/api/packages/<username>/<name>/releases/new/` (Create)
|
|
||||||
* Requires authentication.
|
|
||||||
* Body can be JSON or multipart form data. Zip uploads must be multipart form data.
|
|
||||||
* `title`: human-readable name of the release.
|
|
||||||
* For Git release creation:
|
|
||||||
* `method`: must be `git`.
|
|
||||||
* `ref`: (Optional) git reference, eg: `master`.
|
|
||||||
* For zip upload release creation:
|
|
||||||
* `file`: multipart file to upload, like `<input type="file" name="file">`.
|
|
||||||
* `commit`: (Optional) Source Git commit hash, for informational purposes.
|
|
||||||
* You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/).
|
|
||||||
* DELETE `/api/packages/<username>/<name>/releases/<id>/` (Delete)
|
|
||||||
* Requires authentication.
|
|
||||||
* Deletes release.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create release from Git
|
|
||||||
curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d '{ "method": "git", "title": "My Release", "ref": "master" }'
|
|
||||||
|
|
||||||
# Create release from zip upload
|
|
||||||
curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN" \
|
|
||||||
-F title="My Release" -F file=@path/to/file.zip
|
|
||||||
|
|
||||||
# Create release from zip upload with commit hash
|
|
||||||
curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN" \
|
|
||||||
-F title="My Release" -F commit="8ef74deec170a8ce789f6055a59d43876d16a7ea" -F file=@path/to/file.zip
|
|
||||||
|
|
||||||
# Delete release
|
|
||||||
curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/3/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
* GET `/api/packages/<username>/<name>/screenshots/` (List)
|
|
||||||
* Returns array of screenshot dictionaries with keys:
|
|
||||||
* `id`: screenshot ID
|
|
||||||
* `approved`: true if approved and visible.
|
|
||||||
* `title`: human-readable name for the screenshot, shown as a caption and alt text.
|
|
||||||
* `url`: absolute URL to screenshot.
|
|
||||||
* `created_at`: ISO time.
|
|
||||||
* `order`: Number used in ordering.
|
|
||||||
* `is_cover_image`: true for cover image.
|
|
||||||
* GET `/api/packages/<username>/<name>/screenshots/<id>/` (Read)
|
|
||||||
* Returns screenshot dictionary like above.
|
|
||||||
* POST `/api/packages/<username>/<name>/screenshots/new/` (Create)
|
|
||||||
* Requires authentication.
|
|
||||||
* Body is multipart form data.
|
|
||||||
* `title`: human-readable name for the screenshot, shown as a caption and alt text.
|
|
||||||
* `file`: multipart file to upload, like `<input type=file>`.
|
|
||||||
* `is_cover_image`: set cover image to this.
|
|
||||||
* DELETE `/api/packages/<username>/<name>/screenshots/<id>/` (Delete)
|
|
||||||
* Requires authentication.
|
|
||||||
* Deletes screenshot.
|
|
||||||
* POST `/api/packages/<username>/<name>/screenshots/order/`
|
|
||||||
* Requires authentication.
|
|
||||||
* Body is a JSON array containing the screenshot IDs in their order.
|
|
||||||
* POST `/api/packages/<username>/<name>/screenshots/cover-image/`
|
|
||||||
* Requires authentication.
|
|
||||||
* Body is a JSON dictionary with "cover_image" containing the screenshot ID.
|
|
||||||
|
|
||||||
Currently, to get a different size of thumbnail you can replace the number in `/thumbnails/1/` with any number from 1-3.
|
|
||||||
The resolutions returned may change in the future, and we may move to a more capable thumbnail generation.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create screenshot
|
|
||||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN" \
|
|
||||||
-F title="My Release" -F file=@path/to/screnshot.png
|
|
||||||
|
|
||||||
# Create screenshot and set it as the cover image
|
|
||||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN" \
|
|
||||||
-F title="My Release" -F file=@path/to/screnshot.png -F is_cover_image="true"
|
|
||||||
|
|
||||||
# Delete screenshot
|
|
||||||
curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN"
|
|
||||||
|
|
||||||
# Reorder screenshots
|
|
||||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/order/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d "[13, 2, 5, 7]"
|
|
||||||
|
|
||||||
# Set cover image
|
|
||||||
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/cover-image/ \
|
|
||||||
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d "{ 'cover_image': 123 }"
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Reviews
|
|
||||||
|
|
||||||
* GET `/api/packages/<username>/<name>/reviews/` (List)
|
|
||||||
* Returns array of review dictionaries with keys:
|
|
||||||
* `user`: dictionary with `display_name` and `username`.
|
|
||||||
* `title`: review title
|
|
||||||
* `comment`: the text
|
|
||||||
* `is_positive`: boolean
|
|
||||||
* `created_at`: iso timestamp
|
|
||||||
* `votes`: dictionary with `helpful` and `unhelpful`,
|
|
||||||
* GET `/api/reviews/` (List)
|
|
||||||
* Returns a paginated response. This is a dictionary with `page`, `url`, and `items`.
|
|
||||||
* [Paginated result](#paginated-results)
|
|
||||||
* `items`: array of review dictionaries, like above
|
|
||||||
* Each review also has a `package` dictionary with `type`, `author` and `name`
|
|
||||||
* Query arguments:
|
|
||||||
* `page`: page number, integer from 1 to max
|
|
||||||
* `n`: number of results per page, max 100
|
|
||||||
* `author`: filter by review author username
|
|
||||||
* `is_positive`: true or false. Default: null
|
|
||||||
* `q`: filter by title (case insensitive, no fulltext search)
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```json
|
/api/topics/?q=mobs
|
||||||
[
|
|
||||||
{
|
|
||||||
"comment": "This is a really good mod!",
|
|
||||||
"created_at": "2021-11-24T16:18:33.764084",
|
|
||||||
"is_positive": true,
|
|
||||||
"title": "Really good",
|
|
||||||
"user": {
|
|
||||||
"display_name": "rubenwardy",
|
|
||||||
"username": "rubenwardy"
|
|
||||||
},
|
|
||||||
"votes": {
|
|
||||||
"helpful": 0,
|
|
||||||
"unhelpful": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Topics
|
|
||||||
|
|
||||||
* GET `/api/topics/` ([View](/api/topics/))
|
|
||||||
* See [Topic Queries](#topic-queries)
|
|
||||||
|
|
||||||
### Topic Queries
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
/api/topics/?q=mobs&type=mod&type=game
|
|
||||||
|
|
||||||
Supported query parameters:
|
Supported query parameters:
|
||||||
|
|
||||||
* `q`: Query string.
|
* `q` - Query string.
|
||||||
* `type`: Package types (`mod`, `game`, `txp`).
|
* `sort` - Sort by (`name`, `views`, `date`).
|
||||||
* `sort`: Sort by (`name`, `views`, `created_at`).
|
* `order` - Sort ascending (`asc`) or descending (`desc`).
|
||||||
* `show_added`: Show topics that have an existing package.
|
* `show_added` - Show topics that have an existing package.
|
||||||
* `show_discarded`: Show topics marked as discarded.
|
* `show_discarded` - Show topics marked as discarded.
|
||||||
* `limit`: Return at most `limit` topics.
|
* `limit` - Return at most `limit` topics.
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
### Tags
|
|
||||||
|
|
||||||
* GET `/api/tags/` ([View](/api/tags/)): List of:
|
|
||||||
* `name`: technical name.
|
|
||||||
* `title`: human-readable title.
|
|
||||||
* `description`: tag description or null.
|
|
||||||
* `is_protected`: boolean, whether the tag is protected (can only be set by Editors in the web interface).
|
|
||||||
* `views`: number of views of this tag.
|
|
||||||
|
|
||||||
### Content Warnings
|
|
||||||
|
|
||||||
* GET `/api/content_warnings/` ([View](/api/content_warnings/)): List of:
|
|
||||||
* `name`: technical name
|
|
||||||
* `title`: human-readable title
|
|
||||||
* `description`: tag description or null
|
|
||||||
|
|
||||||
### Licenses
|
|
||||||
|
|
||||||
* GET `/api/licenses/` ([View](/api/licenses/)): List of:
|
|
||||||
* `name`
|
|
||||||
* `is_foss`: whether the license is foss
|
|
||||||
|
|
||||||
### Minetest Versions
|
|
||||||
|
|
||||||
* GET `/api/minetest_versions/` ([View](/api/minetest_versions/))
|
|
||||||
* `name`: Version name.
|
|
||||||
* `is_dev`: boolean, is dev version.
|
|
||||||
* `protocol_version`: protocol version umber.
|
|
||||||
|
|
||||||
|
|
||||||
## Misc
|
|
||||||
|
|
||||||
* GET `/api/scores/` ([View](/api/scores/))
|
|
||||||
* See [Top Packages Algorithm](/help/top_packages/).
|
|
||||||
* Supports [Package Queries](#package-queries).
|
|
||||||
* Returns list of:
|
|
||||||
* `author`: package author name.
|
|
||||||
* `name`: package technical name.
|
|
||||||
* `downloads`: number of downloads.
|
|
||||||
* `score`: total package score.
|
|
||||||
* `score_reviews`: score from reviews.
|
|
||||||
* `score_downloads`: score from downloads.
|
|
||||||
* GET `/api/homepage/` ([View](/api/homepage/)) - get contents of homepage.
|
|
||||||
* `count`: number of packages
|
|
||||||
* `downloads`: get number of downloads
|
|
||||||
* `new`: new packages
|
|
||||||
* `updated`: recently updated packages
|
|
||||||
* `pop_mod`: popular mods
|
|
||||||
* `pop_txp`: popular textures
|
|
||||||
* `pop_game`: popular games
|
|
||||||
* `high_reviewed`: highest reviewed
|
|
||||||
* GET `/api/welcome/v1/` ([View](/api/welcome/v1/)) - in-menu welcome dialog. Experimental (may change without warning)
|
|
||||||
* `featured`: featured games
|
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
title: Contact Us
|
|
||||||
|
|
||||||
## Reports
|
|
||||||
|
|
||||||
Please let us know if anything on the ContentDB violates our rules or any applicable
|
|
||||||
laws.
|
|
||||||
|
|
||||||
We take copyright violation and other offenses very seriously.
|
|
||||||
|
|
||||||
<a href="/report/" class="btn btn-primary">Report</a>
|
|
||||||
|
|
||||||
## Other
|
|
||||||
|
|
||||||
<a href="https://rubenwardy.com/contact/" class="btn btn-primary">Contact the admin</a>
|
|
|
@ -6,36 +6,21 @@ your client to use new flags.
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
Minetest allows you to specify a comma-separated list of flags to hide in the
|
* `nonfree` - can be used to hide packages which do not qualify as
|
||||||
client:
|
'free software', as defined by the Free Software Foundation.
|
||||||
|
* A content rating, given below.
|
||||||
|
|
||||||
```
|
|
||||||
contentdb_flag_blacklist = nonfree, bad_language, drugs
|
|
||||||
```
|
|
||||||
|
|
||||||
A flag can be:
|
## Ratings
|
||||||
|
|
||||||
* `nonfree`: can be used to hide packages which do not qualify as
|
Content ratings aren't currently supported by ContentDB.
|
||||||
'free software', as defined by the Free Software Foundation.
|
Instead, mature content isn't allowed at all for now.
|
||||||
* `wip`: packages marked as Work in Progress
|
|
||||||
* `deprecated`: packages marked as Deprecated
|
|
||||||
* A content warning, given below.
|
|
||||||
* `*`: hides all content warnings.
|
|
||||||
|
|
||||||
There are also two meta-flags, which are designed so that we can change how different platforms filter the package list
|
In the future, more mature content will be allowed but labelled with
|
||||||
without making a release.
|
content ratings which may contain the following:
|
||||||
|
|
||||||
* `android_default`: currently same as `*, deprecated`. Hides all content warnings and deprecated packages
|
* android_default - meta-rating which includes gore and drugs.
|
||||||
* `desktop_default`: currently same as `deprecated`. Hides deprecated packages
|
* desktop_default - meta-rating which won't include anything for now.
|
||||||
|
* gore - more than just blood
|
||||||
## Content Warnings
|
* drugs
|
||||||
|
* swearing
|
||||||
Packages with mature content will be tagged with a content warning based
|
|
||||||
on the content type.
|
|
||||||
|
|
||||||
* `bad_language`: swearing.
|
|
||||||
* `drugs`: drugs or alcohol.
|
|
||||||
* `gambling`
|
|
||||||
* `gore`: blood, etc.
|
|
||||||
* `horror`: shocking and scary content.
|
|
||||||
* `violence`: non-cartoon violence.
|
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
title: Editors
|
|
||||||
|
|
||||||
## What should editors do?
|
|
||||||
|
|
||||||
Editors are users of rank Editor or above.
|
|
||||||
They are responsible for ensuring that the package listings of ContentDB are useful.
|
|
||||||
For this purpose, they can/will:
|
|
||||||
|
|
||||||
* Review and approve packages.
|
|
||||||
* Edit any package - including tags, releases, screenshots, and maintainers.
|
|
||||||
* Create packages on behalf of authors who aren't present.
|
|
||||||
|
|
||||||
Editors should make sure they are familiar with the
|
|
||||||
[Package Inclusion Policy and Guidance](/policy_and_guidance/).
|
|
||||||
|
|
||||||
## ContentDB is not a curated platform
|
|
||||||
|
|
||||||
It's important to note that ContentDB isn't a curated platform, but it also does have some
|
|
||||||
requirements on minimum usefulness. See 2.2 in the [Policy and Guidance](/policy_and_guidance/).
|
|
||||||
|
|
||||||
## Editor Work Queue
|
|
||||||
|
|
||||||
The [Editor Work Queue](/todo/) and related pages contain useful information for editors, such as:
|
|
||||||
|
|
||||||
* The package, release, and screenshot approval queues.
|
|
||||||
* Packages which are outdated or are missing tags.
|
|
||||||
* A list of forum topics without packages.
|
|
||||||
Editors can create the packages or "discard" them if they don't think it's worth adding them.
|
|
||||||
|
|
||||||
## Editor Notifications
|
|
||||||
|
|
||||||
Editors currently receive notifications for any new thread opened on a package, so that they
|
|
||||||
know when a user is asking for help. These notifications are shown separately in the notifications
|
|
||||||
interface, and can be configured separately in Emails and Notifications.
|
|
|
@ -1,50 +0,0 @@
|
||||||
title: Frequently Asked Questions
|
|
||||||
|
|
||||||
## Users and Logins
|
|
||||||
|
|
||||||
### How do I create an account?
|
|
||||||
|
|
||||||
How you create an account depends on whether you have a forum account.
|
|
||||||
|
|
||||||
If you have a forum account, then you'll need to prove that you are the owner of the account. This can
|
|
||||||
be done using a GitHub account or a random string in your forum account signature.
|
|
||||||
|
|
||||||
If you don't, then you can just sign up using an email address and password.
|
|
||||||
|
|
||||||
GitHub can only be used to login, not to register.
|
|
||||||
|
|
||||||
<a class="btn btn-primary" href="/user/claim/">Register</a>
|
|
||||||
|
|
||||||
|
|
||||||
### My verification email never arrived
|
|
||||||
|
|
||||||
There are a number of reasons this may have happened:
|
|
||||||
|
|
||||||
* Incorrect email address entered.
|
|
||||||
* Temporary problem with ContentDB.
|
|
||||||
* Email has been unsubscribed.
|
|
||||||
|
|
||||||
If the email doesn't arrive after registering by email, then you'll need to try registering again in 12 hours.
|
|
||||||
Unconfirmed accounts are deleted after 12 hours.
|
|
||||||
|
|
||||||
If the email verification was sent using the Email settings tab, then you can just set a new email.
|
|
||||||
|
|
||||||
If you have previously unsubscribed this email, then ContentDB is completely prevented from sending emails to that
|
|
||||||
address. You'll need to use a different email address, or [contact rubenwardy](https://rubenwardy.com/contact/) to
|
|
||||||
remove your email from the blacklist.
|
|
||||||
|
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
### How can I create releases automatically?
|
|
||||||
|
|
||||||
There are a number of methods:
|
|
||||||
|
|
||||||
* [Git Update Detection](update_config): ContentDB will check your Git repo daily, and create updates or send you notifications.
|
|
||||||
* [Webhooks](release_webhooks): you can configure your Git host to send a webhook to ContentDB, and create an update immediately.
|
|
||||||
* the [API](api): This is especially powerful when combined with CI/CD and other API endpoints.
|
|
||||||
|
|
||||||
|
|
||||||
## How do I get help?
|
|
||||||
|
|
||||||
Please [contact rubenwardy](https://rubenwardy.com/contact/).
|
|
|
@ -1,137 +0,0 @@
|
||||||
title: Featured Packages
|
|
||||||
|
|
||||||
<p class="alert alert-warning">
|
|
||||||
<b>Note:</b> This is a draft, and is likely to change
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## What are Featured Packages?
|
|
||||||
|
|
||||||
Featured Packages are shown at the top of the ContentDB homepage. In the future,
|
|
||||||
featured packages may be shown inside the Minetest client.
|
|
||||||
|
|
||||||
The purpose is to promote content that demonstrates a high quality of what is
|
|
||||||
possible in Minetest. The selection should be varied, and should vary over time.
|
|
||||||
The featured content should be content that we are comfortable recommending to
|
|
||||||
a first time player.
|
|
||||||
|
|
||||||
## How are the packages chosen?
|
|
||||||
|
|
||||||
Before a package can be considered, it must fulfil the criteria in the below lists.
|
|
||||||
There are three types of criteria:
|
|
||||||
|
|
||||||
* "MUST": These must absolutely be fulfilled, no exceptions!
|
|
||||||
* "SHOULD": Most of them should be fulfilled, if possible. Some of them can be
|
|
||||||
left out if there's a reason.
|
|
||||||
* "CAN": Can be fulfilled for bonus points, they are entirely optional.
|
|
||||||
|
|
||||||
For a chance to get featured, a package must fulfil all "MUST" criteria and
|
|
||||||
ideally as many "SHOULD" criteria as possible. The more, the better. Thankfully,
|
|
||||||
many criteria are trivial to fulfil. Note that ticking off all the boxes is not
|
|
||||||
enough: Just because a package completes the checklist does not make it good.
|
|
||||||
Other aspects of the package should be rated as well. See this list as a
|
|
||||||
starting point, not as an exhaustive quality control.
|
|
||||||
|
|
||||||
Editors are responsible for maintaining the list of featured packages. Authors
|
|
||||||
can request that their package be considered by opening a thread titled
|
|
||||||
"Feature Package" on their package. To speed things up, they should justify
|
|
||||||
why they meet (or don't meet) the below criteria. Editors must abstain from
|
|
||||||
voting on packages where they have a conflict of interest.
|
|
||||||
|
|
||||||
A package being featured does not mean that it will be featured forever. A
|
|
||||||
package may be unfeatured if it no longer meets the criteria, to make space for
|
|
||||||
other packages to be featured, or for another reason.
|
|
||||||
|
|
||||||
## General Requirements
|
|
||||||
|
|
||||||
### General
|
|
||||||
|
|
||||||
* MUST: Be 100% free and open source (as marked as Free on ContentDB).
|
|
||||||
* MUST: Work out-of-the-box (no weird setup or settings required).
|
|
||||||
* MUST: Be compatible with the latest stable Minetest release.
|
|
||||||
* SHOULD: Use public source control (such as Git).
|
|
||||||
* SHOULD: Have at least 3 reviews, and be largely positive.
|
|
||||||
|
|
||||||
### Stability
|
|
||||||
|
|
||||||
* MUST: Be well maintained (author is present and active).
|
|
||||||
* MUST: Be reasonably stable, with no game-breaking or major bugs.
|
|
||||||
* MUST: The author does not consider the package to be in an
|
|
||||||
experimental/development/alpha state. Beta and "unfinished" packages are fine.
|
|
||||||
* MUST: No error messages from the engine (e.g. missing textures).
|
|
||||||
* SHOULD: No major map breakages (including unknown nodes, corruption, loss of inventories).
|
|
||||||
Map breakages are a sign that the package isn't sufficiently stable.
|
|
||||||
|
|
||||||
Note: Any map breakage will be excused if "disaster relief" (i.e. tools to repair the damage)
|
|
||||||
is available.
|
|
||||||
|
|
||||||
### Meta and packaging
|
|
||||||
|
|
||||||
* MUST: `screenshot.png` is present and up-to-date, with a correct aspect ratio (3:2, at least 300x200).
|
|
||||||
* MUST: Have a high resolution cover image on ContentDB (at least 1280x720 pixels).
|
|
||||||
It may be shown cropped to 16:9 aspect ratio, or shorter.
|
|
||||||
* MUST: mod.conf/game.conf/texture_pack.conf present with:
|
|
||||||
* name (if mod or game)
|
|
||||||
* description
|
|
||||||
* dependencies (if relevant)
|
|
||||||
* `min_minetest_version` and `max_minetest_version` (if relevant)
|
|
||||||
* MUST: Contain a README file and a LICENSE file. These may be `.md` or `.txt`.
|
|
||||||
* README files typically contain helpful links (download, manual, bugtracker, etc), and other
|
|
||||||
information that players or (potential) contributors may need.
|
|
||||||
* SHOULD: All important settings are in settingtypes.txt with description.
|
|
||||||
|
|
||||||
## Game-specific Requirements
|
|
||||||
|
|
||||||
### Meta and packaging
|
|
||||||
|
|
||||||
* MUST: Have a main menu icon and header image.
|
|
||||||
|
|
||||||
### Stability
|
|
||||||
|
|
||||||
* MUST: If any major setting (like `enable_damage`) is unsupported, the game must disable it
|
|
||||||
using `disabled_settings` in the `game.conf`, and deal with it appropriately in the code
|
|
||||||
(e.g. force-disable the setting, as the user may still set the setting in `minetest.conf`)
|
|
||||||
|
|
||||||
### Usability
|
|
||||||
|
|
||||||
* MUST: Unsupported mapgens are disabled in game.conf.
|
|
||||||
* SHOULD: Passes the Beginner Test: A newbie to the game (but not Minetest) wouldn't get completely
|
|
||||||
stuck within the first 5 minutes of playing.
|
|
||||||
* SHOULD: Have good documentation. This may include one or more of:
|
|
||||||
* A craftguide, or other in-game learning system
|
|
||||||
* A manual
|
|
||||||
* A wiki
|
|
||||||
* Something else
|
|
||||||
|
|
||||||
### Gameplay
|
|
||||||
|
|
||||||
* CAN: Passes the Six Hour Test (only applies to sandbox games): The game doesn't run out of new
|
|
||||||
content before the first 6 hours of playing.
|
|
||||||
* CAN: Players don't feel that something in the game is "lacking".
|
|
||||||
|
|
||||||
### Audiovisuals
|
|
||||||
|
|
||||||
* MUST: Audiovisual design should be of good quality.
|
|
||||||
* MUST: No obvious GUI/HUD breakages.
|
|
||||||
* MUST: Sounds have no obvious artifacts like clicks or unintentional noise.
|
|
||||||
* SHOULD: Graphical design is mostly consistent.
|
|
||||||
* SHOULD: Sounds are used.
|
|
||||||
* SHOULD: Sounds are normalized (more or less).
|
|
||||||
|
|
||||||
### Quality Assurance
|
|
||||||
|
|
||||||
* MUST: No flooding the console/log file with warnings.
|
|
||||||
* MUST: No duplicate crafting recipes.
|
|
||||||
* MUST: Highly experimental game features are disabled by default.
|
|
||||||
* MUST: Experimental game features are clearly marked as such.
|
|
||||||
* SHOULD: No unknown nodes/items/objects appear.
|
|
||||||
* SHOULD: No dependency on legacy API calls.
|
|
||||||
* SHOULD: No console warnings.
|
|
||||||
|
|
||||||
### Writing
|
|
||||||
|
|
||||||
* MUST: All items that can be obtained in normal gameplay have `description` set (whether in the definition or meta).
|
|
||||||
* MUST: Game is not littered with typos or bad grammar (a few typos are OK but should be fixed, when found).
|
|
||||||
* SHOULD: All items have unique names (items which disguise themselves as another item are exempt).
|
|
||||||
* SHOULD: The writing style of all item names is grammatical and consistent.
|
|
||||||
* SHOULD: Descriptions of things convey useful and meaningful information (if applicable).
|
|
||||||
* CAN: Text is written in clear and (if possible) simple language.
|
|
|
@ -6,14 +6,7 @@ title: Prometheus Metrics
|
||||||
dimensional data model, flexible query language, efficient time series database
|
dimensional data model, flexible query language, efficient time series database
|
||||||
and modern alerting approach".
|
and modern alerting approach".
|
||||||
|
|
||||||
Prometheus Metrics can be accessed at [/metrics](/metrics), or you can view them
|
Prometheus Metrics can be accessed at [/metrics](/metrics).
|
||||||
on the Grafana instance below.
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a class="btn btn-primary" href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb">
|
|
||||||
View ContentDB on Grafana
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
|
|
|
@ -10,13 +10,13 @@ and they will be subject to limited promotion.
|
||||||
|
|
||||||
## How does ContentDB deal with Non-Free Licenses?
|
## How does ContentDB deal with Non-Free Licenses?
|
||||||
|
|
||||||
**ContentDB does not allow certain non-free licenses, and will limit the promotion
|
ContentDB does not allow certain non-free licenses, and will limit the promotion
|
||||||
of packages with non-free licenses.**
|
of packages with non-free licenses.
|
||||||
|
|
||||||
Minetest is free and open source software, and is only as big as it is now
|
Minetest is free and open source software, and is only as big as it is now
|
||||||
because of this. It's pretty amazing you can take nearly any published mod and modify it
|
because of this. It's pretty amazing you can take nearly any published mod and modify it
|
||||||
to how you like - add some features, maybe fix some bugs - and then share those
|
to how you like - add some features, maybe fix some bugs - and then share those
|
||||||
modifications without the worry of legal issues. The project, itself, relies on open
|
modifications without worry of legal issues. The project, itself, relies on open
|
||||||
source contributions to survive - if it were non-free, then it would have died
|
source contributions to survive - if it were non-free, then it would have died
|
||||||
when celeron55 lost interest.
|
when celeron55 lost interest.
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ Here's a quick summary related to Minetest content:
|
||||||
you do want to allow derivative works or combinations.
|
you do want to allow derivative works or combinations.
|
||||||
This means that it can cause problems when another modder wishes to include your
|
This means that it can cause problems when another modder wishes to include your
|
||||||
work in a modpack or game.
|
work in a modpack or game.
|
||||||
2. They may rule out other basic and beneficial uses that you want to allow.
|
2. They may rule out other basic and beneficial uses which you want to allow.
|
||||||
For example, CC -NC will forbid showing your content in a monetised YouTube
|
For example, CC -NC will forbid showing your content in a monetised YouTube
|
||||||
video.
|
video.
|
||||||
3. They are unlikely to increase the potential profit from your work, and a
|
3. They are unlikely to increase the potential profit from your work, and a
|
||||||
|
|
|
@ -2,14 +2,12 @@ title: Package Configuration and Releases Guide
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
ContentDB will read configuration files in your package when doing several
|
ContentDB will read configuration files in your package when doing a number of
|
||||||
tasks, including package and release creation. This page details how you can use
|
tasks, including package and release creation.
|
||||||
this to your advantage.
|
This page details the ways in which you can use this to your advantage.
|
||||||
|
|
||||||
## .conf files
|
## .conf files
|
||||||
|
|
||||||
### What is a content .conf file?
|
|
||||||
|
|
||||||
Every type of content can have a `.conf` file that contains the metadata.
|
Every type of content can have a `.conf` file that contains the metadata.
|
||||||
|
|
||||||
The filename of the `.conf` file depends on the content type:
|
The filename of the `.conf` file depends on the content type:
|
||||||
|
@ -24,95 +22,42 @@ The `.conf` uses a key-value format, separated using equals. Here's a simple exa
|
||||||
name = mymod
|
name = mymod
|
||||||
description = A short description to show in the client.
|
description = A short description to show in the client.
|
||||||
|
|
||||||
### Understood values
|
|
||||||
|
|
||||||
ContentDB understands the following information:
|
ContentDB understands the following information:
|
||||||
|
|
||||||
* `description` - A short description to show in the client.
|
* `description` - A short description to show in the client.
|
||||||
* `depends` - Comma-separated hard dependencies.
|
* `depends` - Comma-separated hard dependencies.
|
||||||
* `optional_depends` - Comma-separated soft dependencies.
|
* `optional_depends` - Comma-separated soft dependencies.
|
||||||
* `min_minetest_version` - The minimum Minetest version this runs on, see [Min and Max Minetest Versions](#min_max_versions).
|
* `min_minetest_version` - The minimum Minetest version this runs on.
|
||||||
* `max_minetest_version` - The maximum Minetest version this runs on, see [Min and Max Minetest Versions](#min_max_versions).
|
* `max_minetest_version` - The maximum Minetest version this runs on.
|
||||||
|
|
||||||
and for mods only:
|
and for mods only:
|
||||||
|
|
||||||
* `name` - the mod technical name.
|
* `name` - the mod technical name.
|
||||||
|
|
||||||
|
|
||||||
## .cdb.json
|
|
||||||
|
|
||||||
You can include a `.cdb.json` file in the root of your content directory (ie: next to a .conf)
|
|
||||||
to update the package meta.
|
|
||||||
|
|
||||||
It should be a JSON dictionary with one or more of the following optional keys:
|
|
||||||
|
|
||||||
* `type`: One of `GAME`, `MOD`, `TXP`.
|
|
||||||
* `title`: Human-readable title.
|
|
||||||
* `name`: Technical name (needs permission if already approved).
|
|
||||||
* `short_description`
|
|
||||||
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
|
|
||||||
`LOOKING_FOR_MAINTAINER`.
|
|
||||||
* `tags`: List of tag names, see [/api/tags/](/api/tags/).
|
|
||||||
* `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/).
|
|
||||||
* `license`: A license name, see [/api/licenses/](/api/licenses/).
|
|
||||||
* `media_license`: A license name.
|
|
||||||
* `long_description`: Long markdown description.
|
|
||||||
* `repo`: Git repo URL.
|
|
||||||
* `website`: Website URL.
|
|
||||||
* `issue_tracker`: Issue tracker URL.
|
|
||||||
* `forums`: forum topic ID.
|
|
||||||
* `video_url`: URL to a video.
|
|
||||||
|
|
||||||
Use `null` to unset fields where relevant.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "Foo bar",
|
|
||||||
"tags": ["pvp", "survival"],
|
|
||||||
"license": "MIT",
|
|
||||||
"website": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Controlling Release Creation
|
## Controlling Release Creation
|
||||||
|
|
||||||
### Git-based Releases and Submodules
|
### Git Releases and Submodules
|
||||||
|
|
||||||
ContentDB can create releases from a Git repository.
|
ContentDB can automatically create releases from a git repository.
|
||||||
It will include submodules in the resulting archive.
|
It will include submodules in the resulting archive.
|
||||||
Simply set VCS Repository in the package's meta to a Git repository, and then
|
|
||||||
choose Git as the method when creating a release.
|
|
||||||
|
|
||||||
### Automatic Release Creation
|
### Automatic Release Creation
|
||||||
|
|
||||||
See [Git Update Detection](/help/update_config/).
|
The preferred way is to use [webhooks from GitLab or GitHub](/help/release_webhooks/).
|
||||||
You can also use [GitLab/GitHub webhooks](/help/release_webhooks/) or the [API](/help/api/)
|
You can also use the [API](/help/api/) to create releases.
|
||||||
to create releases.
|
|
||||||
|
|
||||||
### Min and Max Minetest Versions
|
### Min and Max Minetest Versions
|
||||||
|
|
||||||
<a name="min_max_versions" />
|
|
||||||
|
|
||||||
When creating a release, the `.conf` file will be read to determine what Minetest
|
When creating a release, the `.conf` file will be read to determine what Minetest
|
||||||
versions the release supports. If the `.conf` doesn't specify, then it is assumed
|
versions the release supports. If the `.conf` doesn't specify, then it is assumed
|
||||||
that it supports all versions.
|
that is supports all versions.
|
||||||
|
|
||||||
This happens when you create a release via the ContentDB web interface, the
|
This happens when you create a release via the ContentDB web interface, the
|
||||||
[API](/help/api/), or using a [GitLab/GitHub webhook](/help/release_webhooks/).
|
[API](/help/api/), or using a [GitLab/GitHub webhook](/help/release_webhooks/).
|
||||||
|
|
||||||
Here's an example config:
|
|
||||||
|
|
||||||
name = mymod
|
|
||||||
min_minetest_version = 5.0
|
|
||||||
max_minetest_version = 5.3
|
|
||||||
|
|
||||||
Leaving out min or max to have them set as "None".
|
|
||||||
|
|
||||||
### Excluding files
|
### Excluding files
|
||||||
|
|
||||||
When using Git to create releases,
|
When using git to create releases,
|
||||||
you can exclude files from a release by using [gitattributes](https://git-scm.com/docs/gitattributes):
|
you can exclude files from a release by using [gitattributes](https://git-scm.com/docs/gitattributes):
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
title: Package Tags
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Tags should be added to packages to enable easy identification of different types of mods, games and texture packs.
|
||||||
|
|
||||||
|
They are only beneficial when applied correctly, so please use the following guidelines.
|
||||||
|
|
||||||
|
## Tag Usage
|
||||||
|
|
||||||
|
* **Building** - For mods that focus on adding new materials or nodes to build with.
|
||||||
|
* **Education** - For mods or games created for educational purposes.
|
||||||
|
* **Environment** - For mods that add environmental effects, including ambient sound and weather effects.
|
||||||
|
* **Inventory** - For mods that add new inventory systems or new inventory pages.
|
||||||
|
* **Machines and Electronics** - For mods that include placeable machinery or electronic components which interact to complete tasks.
|
||||||
|
* **Maintenance** - For mods that assist with world or player maintenance. This includes large-scale map manipulation, area protection and other administrative tools.
|
||||||
|
* **Mapgen** - For mods that add new biomes, new mapgen decorations, or any other mapgen elements.
|
||||||
|
* **Mobs and NPCs** - For mods that add mobs or NPCs, or provide tools that assist with mob and NPC creation or manipulation.
|
||||||
|
* **Plants and Farming** - For mods that add new plants or other farmable resources.
|
||||||
|
* **Player effects/Food** - For mods that change player effects, for example speed, jump height or gravity, and food.
|
||||||
|
* **Tools** - For mods that add new tools or new features for existing tools.
|
||||||
|
* **Transport** - For mods that add transportation methods. This includes teleportation, vehicles and ridable mobs.
|
||||||
|
* **Survival** - For mods written specifically for survival games. For example, these mods might focus on game-balance or increase the difficulty level. This tag should also be used for games with a heavy survival focus.
|
||||||
|
* **Creative** - For mods written specifically (and often exclusively) for use in creative mode. For example, these mods may add a large amount of decorative content, or content without crafting recipes. This tag should also be used for games with a heavy creative focus.
|
||||||
|
* **Multiplayer-focused** - For games that can be played with other players.
|
||||||
|
* **Singleplayer-focused** - For games that can be played alone.
|
||||||
|
* **PvP** - For games designed to be played competitively against other players.
|
||||||
|
* **PvE** - For games designed for one or multiple players which focus on combat against mobs or NPCs.
|
||||||
|
* **Puzzle** - For mods and games with a focus on puzzle solving instead of combat.
|
||||||
|
* **16px** - For 16px texture packs.
|
||||||
|
* **32px** - For 32px texture packs.
|
||||||
|
* **64px** - For 64px texture packs.
|
||||||
|
* **128px+** - For 128px or higher texture packs.
|
|
@ -3,26 +3,24 @@ title: Ranks and Permissions
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
* **New Members** - mostly untrusted, cannot change package meta data or publish releases without approval.
|
* **New Members** - mostly untrusted, cannot change package meta data or publish releases without approval.
|
||||||
* **Members** - Trusted to change the meta data of their own packages', but cannot approve their own packages.
|
* **Members** - Trusted to change the meta data of their own packages', but cannot publish releases.
|
||||||
* **Trusted Members** - Same as above, but can approve their own releases.
|
* **Trusted Members** - Same as above, but can approve their own releases and packages.
|
||||||
* **Approvers** - Responsible for approving new packages, screenshots, and releases.
|
* **Editors** - Trusted to change the meta data of any package, and also make and publish releases.
|
||||||
* **Editors** - Same as above, and can edit any package or release.
|
|
||||||
* **Moderators** - Same as above, but can manage users.
|
* **Moderators** - Same as above, but can manage users.
|
||||||
* **Admins** - Full access.
|
* **Admins** - Full access.
|
||||||
|
|
||||||
## Breakdown
|
## Breakdown
|
||||||
|
|
||||||
<table class="table table-striped ranks-table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Rank</th>
|
<th>Rank</th>
|
||||||
<th colspan=2 class="NEW_MEMBER">New Member</th>
|
<th colspan=2>New Member</th>
|
||||||
<th colspan=2 class="MEMBER">Member</th>
|
<th colspan=2>Member</th>
|
||||||
<th colspan=2 class="TRUSTED_MEMBER">Trusted</th>
|
<th colspan=2>Trusted Member</th>
|
||||||
<th colspan=2 class="APPROVER">Approver</th>
|
<th colspan=2>Editor</th>
|
||||||
<th colspan=2 class="EDITOR">Editor</th>
|
<th colspan=2>Moderator</th>
|
||||||
<th colspan=2 class="MODERATOR">Moderator</th>
|
<th colspan=2>Admin</th>
|
||||||
<th colspan=2 class="ADMIN">Admin</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Owner of thing</th>
|
<th>Owner of thing</th>
|
||||||
|
@ -38,269 +36,238 @@ title: Ranks and Permissions
|
||||||
<th>N</th>
|
<th>N</th>
|
||||||
<th>Y</th>
|
<th>Y</th>
|
||||||
<th>N</th>
|
<th>N</th>
|
||||||
<th>Y</th>
|
|
||||||
<th>N</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Create Package</td>
|
<td>Create Package</td>
|
||||||
<td>✓</td> <!-- new -->
|
<th>✓</th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th>✓</th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td></td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Approve Package</td>
|
<td>Approve Package</td>
|
||||||
<td></td> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- member -->
|
<th></th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- trusted member -->
|
<th></th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Delete Package</td>
|
<td>Delete Package</td>
|
||||||
<td></td> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th></th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th></th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Edit Package</td>
|
<td>Edit Package</td>
|
||||||
<td></td> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th>✓</th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td></td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Edit Maintainers</td>
|
<td>Edit Maintainers</td>
|
||||||
<td>✓</td> <!-- new -->
|
<th>✓</th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th>✓</th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Add/Delete Screenshot</td>
|
<td>Add/Delete Screenshot</td>
|
||||||
<td>✓</td> <!-- new -->
|
<th>✓</th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th>✓</th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td></td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Approve Screenshot</td>
|
<td>Approve Screenshot</td>
|
||||||
<td></td> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- member -->
|
<th></th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Make Release</td>
|
<td>Make Release</td>
|
||||||
<td>✓</td> <!-- new -->
|
<th>✓</th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th>✓</th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td></td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Approve Release</td>
|
<td>Approve Release</td>
|
||||||
<td></td> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th></th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Change Release URL</td>
|
<td>Change Release URL</td>
|
||||||
<td></td> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- member -->
|
<th></th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- trusted member -->
|
<th></th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- approver -->
|
<th></th> <!-- editor -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- editor -->
|
<th></th> <!-- moderator -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td></td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>See Private Thread</td>
|
<td>See Private Thread</td>
|
||||||
<td>✓</td> <!-- new -->
|
<th>✓</th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th>✓</th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Edit Comments</td>
|
<td>Edit Comments</td>
|
||||||
<td></td> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th>✓</th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Set Email</td>
|
<td>Set Email</td>
|
||||||
<td>✓</td> <!-- new -->
|
<th>✓</th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th>✓</th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td></td>
|
|
||||||
<td>✓</td> <!-- moderator -->
|
|
||||||
<th>✓<sup>2</sup></th>
|
<th>✓<sup>2</sup></th>
|
||||||
<td>✓</td> <!-- admin -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Create Token</td>
|
<td>Create Token</td>
|
||||||
<td></td> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- member -->
|
<th>✓</th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- trusted member -->
|
<th>✓</th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- approver -->
|
<th>✓</th> <!-- editor -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td>✓</td> <!-- editor -->
|
<th>✓</th> <!-- moderator -->
|
||||||
<td></td>
|
|
||||||
<td>✓</td> <!-- moderator -->
|
|
||||||
<th>✓<sup>2</sup></th>
|
<th>✓<sup>2</sup></th>
|
||||||
<td>✓</td> <!-- admin -->
|
<th>✓</th> <!-- admin -->
|
||||||
<td>✓</td>
|
<th>✓</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Set Rank</td>
|
<td>Set Rank</td>
|
||||||
<td></td> <!-- new -->
|
<th></th> <!-- new -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- member -->
|
<th></th> <!-- member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- trusted member -->
|
<th></th> <!-- trusted member -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- approver -->
|
<th></th> <!-- editor -->
|
||||||
<td></td>
|
<th></th>
|
||||||
<td></td> <!-- editor -->
|
<th>✓<sup>3</sup></th> <!-- moderator -->
|
||||||
<td></td>
|
<th>✓<sup>2</sup><sup>3</sup></th>
|
||||||
<th>✓<sup>2</sup></th> <!-- moderator -->
|
<th>✓</th> <!-- admin -->
|
||||||
<th>✓<sup>1</sup><sup>2</sup></th>
|
<th>✓</th>
|
||||||
<td>✓</td> <!-- admin -->
|
|
||||||
<td>✓</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
||||||
1. Target user cannot be an admin.
|
1. User must be the author of the EditRequest.
|
||||||
2 Cannot set user to a higher rank than themselves.
|
2. Target user cannot be an admin.
|
||||||
|
3. Cannot set user to a higher rank than themselves.
|
||||||
|
|
|
@ -9,25 +9,32 @@ ContentDB offers the ability to automatically create releases using webhooks
|
||||||
from either Github or Gitlab. If you're not using either of those services,
|
from either Github or Gitlab. If you're not using either of those services,
|
||||||
you can also use the [API](../api) to create releases.
|
you can also use the [API](../api) to create releases.
|
||||||
|
|
||||||
ContentDB also offers the ability to poll a Git repo and check for updates
|
|
||||||
without any web hooks, this is limited to once a day.
|
|
||||||
See [Git Update Detection](/help/update_config/).
|
|
||||||
|
|
||||||
The process is as follows:
|
The process is as follows:
|
||||||
|
|
||||||
1. The user creates an API Token and a webhook to use it.
|
1. The user creates an API Token and a webhook to use it. This can be done automatically
|
||||||
|
for Github.
|
||||||
2. The user pushes a commit to the git host (Gitlab or Github).
|
2. The user pushes a commit to the git host (Gitlab or Github).
|
||||||
3. The git host posts a webhook notification to ContentDB, using the API token assigned to it.
|
3. The git host posts a webhook notification to ContentDB, using the API token assigned to it.
|
||||||
4. ContentDB checks the API token and issues a new release.
|
4. ContentDB checks the API token and issues a new release.
|
||||||
|
|
||||||
<p class="alert alert-warning">
|
|
||||||
"New commit" or "push" based webhooks will currently only work on branches named `master` or
|
|
||||||
`main`.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## Setting up
|
## Setting up
|
||||||
|
|
||||||
### GitHub
|
### GitHub (automatic)
|
||||||
|
|
||||||
|
1. Go to your package's page.
|
||||||
|
2. Make sure that the repository URL is set to a Github repository.
|
||||||
|
Only github.com is supported.
|
||||||
|
3. Go to "Releases" > "+", and click "Setup webhook" at the top of the create release
|
||||||
|
page.
|
||||||
|
If you do not see this, either the repository isn't using Github or you do
|
||||||
|
not have permission to use webhook releases (ie: you're not a Trusted Member).
|
||||||
|
4. Grant ContentDB the ability to manage Webhooks.
|
||||||
|
5. Set the event to either "New tag or Github Release" (highly recommended) or "Push".
|
||||||
|
|
||||||
|
N.B.: GitHub uses tags to power GitHub Releases, meaning that creating a webhook
|
||||||
|
on "New tag" will sync GitHub and ContentDB releases.
|
||||||
|
|
||||||
|
### GitHub (manual)
|
||||||
|
|
||||||
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
|
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
|
||||||
2. Copy the access token that was generated.
|
2. Copy the access token that was generated.
|
||||||
|
@ -41,7 +48,7 @@ The process is as follows:
|
||||||
choose "Let me select" > Branch or tag creation.
|
choose "Let me select" > Branch or tag creation.
|
||||||
8. Create.
|
8. Create.
|
||||||
|
|
||||||
### GitLab
|
### GitLab (manual)
|
||||||
|
|
||||||
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
|
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
|
||||||
2. Copy the access token that was generated.
|
2. Copy the access token that was generated.
|
||||||
|
@ -54,10 +61,17 @@ The process is as follows:
|
||||||
choose "Tag push events".
|
choose "Tag push events".
|
||||||
8. Add webhook.
|
8. Add webhook.
|
||||||
|
|
||||||
## Configuring Release Creation
|
## Configuring
|
||||||
|
|
||||||
See the [Package Configuration and Releases Guide](/help/package_config/) for
|
### Setting minimum and maximum Minetest versions
|
||||||
documentation on configuring the release creation.
|
|
||||||
|
|
||||||
From the Git repository, you can set the min/max Minetest versions, which files are included,
|
1. Open up the conf file for the package.
|
||||||
and update the package meta.
|
This will be `game.conf`, `mod.conf`, `modpack.conf`, or `texture_pack.conf`
|
||||||
|
depending on the content type.
|
||||||
|
2. Set `min_minetest_version` and `max_minetest_version` to the respective Minetest versions.
|
||||||
|
|
||||||
|
Eg:
|
||||||
|
|
||||||
|
min_minetest_version = 5.0
|
||||||
|
|
||||||
|
Also see [Package Configuration and Releases Guide](/help/package_config/).
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
title: Reporting Content
|
||||||
|
|
||||||
|
Please let us know if anything on the ContentDB violates our rules or any applicable
|
||||||
|
laws.
|
||||||
|
|
||||||
|
We take copyright violation and other offenses very seriously.
|
||||||
|
|
||||||
|
<a href="https://rubenwardy.com/contact/" class="btn btn-success">Contact</a>
|
|
@ -1,32 +1,46 @@
|
||||||
title: Top Packages Algorithm
|
title: Top Packages Algorithm
|
||||||
|
|
||||||
## Package Score
|
## Score
|
||||||
|
|
||||||
Each package is given a `score`, which is used when ordering them in the
|
A package's score is currently equal to a pseudo rolling average of downloads,
|
||||||
"Top Games/Mods/Texture Packs" lists. The intention of this feature is
|
plus the sum of review scores.
|
||||||
to make it easier for new users to find good packages.
|
|
||||||
|
|
||||||
A package's score is equal to a rolling average of recent downloads,
|
|
||||||
plus the sum of the score given by reviews.
|
|
||||||
|
|
||||||
A review score is 100 if positive, -100 if negative.
|
A review score is 100 if positive, -100 if negative.
|
||||||
|
|
||||||
```c
|
score = avg_downloads + Σ 100 * (positive ? 1 : -1)
|
||||||
reviews_sum = sum(100 * (positive ? 1 : -1));
|
|
||||||
score = avg_downloads + reviews_sum;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pseudo rolling average of downloads
|
## Pseudo rolling average of downloads
|
||||||
|
|
||||||
Each package adds 1 to `avg_downloads` for each unique download,
|
Every package loses 5% of its score every day, and will gain 1 score for each
|
||||||
and then loses 5% (=1/20) of the value every day.
|
unique download.
|
||||||
|
|
||||||
This is called a [Frecency](https://en.wikipedia.org/wiki/Frecency) heuristic,
|
This metric aims to be roughly equivalent to the average downloads.
|
||||||
a measure which combines both frequency and recency.
|
|
||||||
|
|
||||||
"Unique download" is counted per IP per package.
|
## Seeded using a legacy heuristic
|
||||||
Downloading an update won't increase the download count if it's already been
|
|
||||||
downloaded from that IP.
|
The scoring system was seeded (ie: the scores were initially set to) 20% of an
|
||||||
|
arbitrary legacy heuristic that was previously used to rank packages.
|
||||||
|
|
||||||
|
This legacy heuristic is as follows:
|
||||||
|
|
||||||
|
forum_score = views / max(years_since_creation, 2 weeks) + 80*clamp(months, 0.5, 6)
|
||||||
|
forum_bonus = views + posts
|
||||||
|
|
||||||
|
multiplier = 1
|
||||||
|
if no screenshot:
|
||||||
|
multiplier *= 0.8
|
||||||
|
|
||||||
|
score = multiplier * (max(downloads, forum_score * 0.6) + forum_bonus)
|
||||||
|
|
||||||
|
As said, this legacy score is no longer used when ranking mods.
|
||||||
|
It was only used to provide an initial score for the rolling average,
|
||||||
|
which was 20% of the above value.
|
||||||
|
|
||||||
|
## Manual adjustments
|
||||||
|
|
||||||
|
The admin occasionally reduces all packages by a set percentage to speed up
|
||||||
|
convergence. Convergence is when the pseudo-rolling average matches the actual
|
||||||
|
rolling average - the effect of the legacy heuristic is gone.
|
||||||
|
|
||||||
## Transparency and Feedback
|
## Transparency and Feedback
|
||||||
|
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
title: Git Update Detection
|
|
||||||
|
|
||||||
## Introduction
|
|
||||||
|
|
||||||
When you push a change to your Git repository, ContentDB can create a new release automatically or
|
|
||||||
send you a reminder. ContentDB will check your Git repository one per day, but you can use
|
|
||||||
webhooks or the API for faster updates.
|
|
||||||
|
|
||||||
Git Update Detection is clever enough to not create a release again if you've already created
|
|
||||||
it manually or using webhooks/the API.
|
|
||||||
|
|
||||||
## Setting up
|
|
||||||
|
|
||||||
* Set "VCS Repository URL" in your package.
|
|
||||||
* Open the "Configure Git Update Detection" page:
|
|
||||||
* Go to the Create Release page and click "Set up" on the banner.
|
|
||||||
* If the "How do you want to create releases?" wizard appears, choose "Automatic".
|
|
||||||
* Choose a trigger:
|
|
||||||
* **New Commit** - this will trigger for each pushed commit on the default branch, or the branch you specify.
|
|
||||||
* **New Tag** - this will trigger when a New Tag is created.
|
|
||||||
* Choose action to occur when the trigger happens:
|
|
||||||
* **Notification** - All maintainers receive a notification under the Bot category, and the package
|
|
||||||
will appear under "Outdated Packages" in [your to do list](/user/todo/).
|
|
||||||
* **Create Release** - A new release is created.
|
|
||||||
If New Commit, the title will be the iso date (eg: 2021-02-01).
|
|
||||||
If New Tag, the title will the tag name.
|
|
||||||
|
|
||||||
## Marking a package as up-to-date
|
|
||||||
|
|
||||||
Git Update Detection shouldn't erroneously mark packages as outdated if it is configured currently,
|
|
||||||
so the first thing you should do is make sure the Update Settings are set correctly.
|
|
||||||
|
|
||||||
There are some situations where the settings are correct, but you want to mark a package as
|
|
||||||
up-to-date - for example, if you don't want to make a release for a particular tag.
|
|
||||||
Clicking "Save" on "Update Settings" will mark a package as up-to-date.
|
|
||||||
|
|
||||||
## Configuring Release Creation
|
|
||||||
|
|
||||||
See the [Package Configuration and Releases Guide](/help/package_config/) for
|
|
||||||
documentation on configuring the release creation.
|
|
||||||
|
|
||||||
From the Git repository, you can set the min/max Minetest versions, which files are included,
|
|
||||||
and update the package meta.
|
|
|
@ -1,12 +1,12 @@
|
||||||
title: WTFPL is a terrible license
|
title: WTFPL is a terrible license
|
||||||
toc: False
|
no_h1: true
|
||||||
|
|
||||||
<div id="warning" class="alert alert-warning">
|
<div id="warning" class="alert alert-warning">
|
||||||
<span class="icon_message"></span>
|
<span class="icon_message"></span>
|
||||||
|
|
||||||
Please reconsider the choice of WTFPL as a license.
|
Please reconsider the choice of WTFPL as a license.
|
||||||
|
|
||||||
<script src="/static/libs/jquery.min.js"></script>
|
<script src="/static/jquery.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// @author rubenwardy
|
// @author rubenwardy
|
||||||
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
|
||||||
|
@ -20,6 +20,8 @@ toc: False
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
# WTFPL is a terrible license
|
||||||
|
|
||||||
The use of WTFPL as a license is discouraged for multiple reasons.
|
The use of WTFPL as a license is discouraged for multiple reasons.
|
||||||
|
|
||||||
* **No Warranty disclaimer:** This could open you up to being sued.<sup>[1]</sup>
|
* **No Warranty disclaimer:** This could open you up to being sued.<sup>[1]</sup>
|
||||||
|
|
|
@ -1,17 +1,23 @@
|
||||||
title: Package Inclusion Policy and Guidance
|
title: Package Inclusion Policy and Guidance
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<b>Note:</b> This is a draft
|
||||||
|
</div>
|
||||||
|
|
||||||
## 0. Overview
|
## 0. Overview
|
||||||
|
|
||||||
ContentDB is for the community, and as such listings should be useful to the
|
ContentDB is for the community, and as such listings should be useful to the
|
||||||
community. To help with this, there are a few rules to improve the quality of
|
community. To help with this, there are a few rules to improve the quality of
|
||||||
the listings and to combat abuse.
|
the listings and to combat abuse.
|
||||||
|
|
||||||
* **No inappropriate content.** <sup>2.1</sup>
|
* No inappropriate content. <sup>2.1</sup>
|
||||||
* **Content must be playable/useful, but not necessarily finished.** <sup>2.2</sup>
|
* Content must be playable/useful, but not necessarily finished. <sup>2.2</sup>
|
||||||
* **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup>
|
* Don't use the name of another mod unless your mod is a fork or reimplementation. <sup>3</sup>
|
||||||
* **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup>
|
* Licenses must allow derivatives, redistribution, and must not discriminate. <sup>4</sup>
|
||||||
* **Don't put promotions or advertisements in any package metadata.** <sup>5</sup>
|
* Don't put promotions or advertisements in package listings, except for
|
||||||
* **The ContentDB admin reserves the right to remove packages for any reason**,
|
donation and personal website links which are permitted in the
|
||||||
|
long description. <sup>5</sup>
|
||||||
|
* The ContentDB admin reserves the right to remove packages for any reason,
|
||||||
including ones not covered by this document, and to ban users who abuse
|
including ones not covered by this document, and to ban users who abuse
|
||||||
this service. <sup>1</sup>
|
this service. <sup>1</sup>
|
||||||
|
|
||||||
|
@ -21,34 +27,32 @@ the listings and to combat abuse.
|
||||||
The ContentDB admin reserves the right to remove packages for any reason,
|
The ContentDB admin reserves the right to remove packages for any reason,
|
||||||
including ones not covered by this document, and to ban users who abuse this service.
|
including ones not covered by this document, and to ban users who abuse this service.
|
||||||
|
|
||||||
|
Also see the [help page on tags](/help/package_tags/).
|
||||||
|
|
||||||
|
|
||||||
## 2. Accepted Content
|
## 2. Accepted Content
|
||||||
|
|
||||||
### 2.1. Acceptable Content
|
### 2.1. Acceptable Content
|
||||||
|
|
||||||
Sexually-orientated content is not permitted.
|
Sexually-orientated content is not permitted.
|
||||||
If in doubt at what this means, [contact us by raising a report](/report/).
|
|
||||||
|
|
||||||
Mature content is permitted providing that it is labelled correctly.
|
Mature content, including that relating to drugs, excessive gore, violence, or
|
||||||
See [Content Flags](/help/content_flags/).
|
excessive horror, is not currently permitted - but will be in the future.
|
||||||
|
|
||||||
The submission of malware is strictly prohibited. This includes software that
|
The submission of malware is strictly prohibited. This includes software which
|
||||||
does not do as it advertises, for example, if it posts telemetry without stating
|
does not do as it advertises, for example if it posts telemetry without stating
|
||||||
clearly that it does in the package meta.
|
clearly that it does in the package meta.
|
||||||
|
|
||||||
### 2.2. State of Completion
|
### 2.2. State of Completion
|
||||||
|
|
||||||
ContentDB should only currently contain playable content - content which is
|
ContentDB should only currently contain playable content - content which is
|
||||||
sufficiently complete to be useful to end-users. It's fine to add stuff which
|
sufficiently complete to be useful to end users. It's fine to add stuff which
|
||||||
is still a Work in Progress (WIP) as long as it adds sufficient value;
|
is still a work in progress (WIP) as long as it adds sufficient value -
|
||||||
MineClone 2 is a good example of a WIP package which may break between releases
|
MineClone 2 is a good example of a WIP package which may break between releases
|
||||||
but still has value. Note that this doesn't mean that you should add a thing
|
but still has value. Note that this doesn't mean that you should add a thing
|
||||||
you started working on yesterday, it's worth adding all the basic stuff to
|
you started working on yesterday, it's worth adding all the basic stuff to
|
||||||
make your package useful.
|
make your package useful.
|
||||||
|
|
||||||
You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
|
|
||||||
as this will help advise players.
|
|
||||||
|
|
||||||
Adding non-player facing mods, such as libraries and server tools, is perfectly fine
|
Adding non-player facing mods, such as libraries and server tools, is perfectly fine
|
||||||
and encouraged. ContentDB isn't just for player-facing things, and adding
|
and encouraged. ContentDB isn't just for player-facing things, and adding
|
||||||
libraries allows them to be installed when a mod depends on it.
|
libraries allows them to be installed when a mod depends on it.
|
||||||
|
@ -58,10 +62,8 @@ libraries allows them to be installed when a mod depends on it.
|
||||||
|
|
||||||
### 3.1 Right to a name
|
### 3.1 Right to a name
|
||||||
|
|
||||||
A package uses a name when it has that name or contains a mod that uses that name.
|
|
||||||
|
|
||||||
The first package to use a name based on the creation of its forum topic or
|
The first package to use a name based on the creation of its forum topic or
|
||||||
ContentDB submission has the right to the technical name. The use of a package
|
contentdb submission has the right to the technical name. The use of a package
|
||||||
on a server or in private doesn't reserve its name. No other packages of the same
|
on a server or in private doesn't reserve its name. No other packages of the same
|
||||||
type may use the same name, except for the exception given by 3.2.
|
type may use the same name, except for the exception given by 3.2.
|
||||||
|
|
||||||
|
@ -77,7 +79,7 @@ We reserve the right to issue exceptions for this where we feel necessary.
|
||||||
### 3.2. Mod Forks and Reimplementations
|
### 3.2. Mod Forks and Reimplementations
|
||||||
|
|
||||||
An exception to the above is that mods are allowed to have the same name as a
|
An exception to the above is that mods are allowed to have the same name as a
|
||||||
mod if it's a fork of that mod (or a close reimplementation). In real terms, it
|
mod if its a fork of that mod (or a close reimplementation). In real terms, it
|
||||||
should be possible to use the new mod as a drop-in replacement.
|
should be possible to use the new mod as a drop-in replacement.
|
||||||
|
|
||||||
We reserve the right to decide whether a mod counts as a fork or
|
We reserve the right to decide whether a mod counts as a fork or
|
||||||
|
@ -91,9 +93,9 @@ reimplementation of the mod that owns the name.
|
||||||
Please ensure that you correctly credit any resources (code, assets, or otherwise)
|
Please ensure that you correctly credit any resources (code, assets, or otherwise)
|
||||||
that you have used in your package.
|
that you have used in your package.
|
||||||
|
|
||||||
**The use of licenses that do not allow derivatives or redistribution is not
|
**The use of licenses which do not allow derivatives or redistribution is not
|
||||||
permitted. This includes CC-ND (No-Derivatives) and lots of closed source licenses.
|
permitted. This includes CC-ND (No-Derivatives) and lots of closed source licenses.
|
||||||
The use of licenses that discriminate between groups of people or forbid the use
|
The use of licenses which discriminate between groups of people or forbid the use
|
||||||
of the content on servers or singleplayer is also not permitted.**
|
of the content on servers or singleplayer is also not permitted.**
|
||||||
|
|
||||||
However, closed sourced licenses are allowed if they allow the above.
|
However, closed sourced licenses are allowed if they allow the above.
|
||||||
|
@ -106,19 +108,15 @@ of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html
|
||||||
|
|
||||||
### 4.2. Recommended Licenses
|
### 4.2. Recommended Licenses
|
||||||
|
|
||||||
It is highly recommended that you use a Free and Open Source software (FOSS)
|
It is highly recommended that you use a free and open source software license.
|
||||||
license. FOSS licenses result in a sharing community and will increase the
|
FOSS licenses result in a sharing community and will increase the number of potential users your package has.
|
||||||
number of potential users your package has. Using a closed source license will
|
Using a closed source license will result in your package being massively penalised in the search results and package lists.
|
||||||
result in your package being massively penalised in the search results and
|
|
||||||
package lists. See the help page on [non-free licenses](/help/non_free/) for more
|
|
||||||
information.
|
|
||||||
|
|
||||||
It is recommended that you use a proper license for code with a warranty
|
It is recommended that you use a proper license for code with a warranty
|
||||||
disclaimer, such as the (L)GPL or MIT. You should also use a proper media license
|
disclaimer, such as the (L)GPL or MIT. You should also use a proper media license
|
||||||
for media, such as a Creative Commons license.
|
for media, such as a Creative Commons license.
|
||||||
|
|
||||||
The use of WTFPL is discouraged as it doesn't contain a
|
The use of WTFPL is discouraged as it doesn't contain a [valid warranty disclaimer](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html),
|
||||||
[valid warranty disclaimer](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html),
|
|
||||||
and also includes swearing which prevents settings like schools from using your content.
|
and also includes swearing which prevents settings like schools from using your content.
|
||||||
[Read more](/help/wtfpl/).
|
[Read more](/help/wtfpl/).
|
||||||
|
|
||||||
|
@ -127,7 +125,7 @@ Public domain is not a valid license in many countries, please use CC0 or MIT in
|
||||||
|
|
||||||
## 5. Promotions and Advertisements (inc. asking for donations)
|
## 5. Promotions and Advertisements (inc. asking for donations)
|
||||||
|
|
||||||
You may not place any promotions or advertisements in any meta data including
|
You may note place any promotions or advertisements in any meta data including
|
||||||
screenshots. This includes asking for donations, promoting online shops,
|
screenshots. This includes asking for donations, promoting online shops,
|
||||||
or linking to personal websites and social media. Please instead use the
|
or linking to personal websites and social media. Please instead use the
|
||||||
fields provided on your user profile page to place links to websites and
|
fields provided on your user profile page to place links to websites and
|
||||||
|
@ -137,20 +135,6 @@ ContentDB is for the community. We may remove any promotions if we feel that
|
||||||
they're inappropriate.
|
they're inappropriate.
|
||||||
|
|
||||||
|
|
||||||
## 6. Reviews and Package Score
|
## 6. Reporting Violations
|
||||||
|
|
||||||
You may invite players to review your package(s). One way to do this is by sharing the link found in the
|
See the [Reporting Content](/help/reporting/) page.
|
||||||
"Share and Badges" page of the package's settings.
|
|
||||||
|
|
||||||
You must not require anyone to review a package. You must not promise or provide incentives for reviewing a package,
|
|
||||||
including but not limited to monetary rewards, in-game items, features, and/or privileges.
|
|
||||||
You may give a cosmetic-only role or badge to those who review your package - this must not be tied to the content or
|
|
||||||
rating of the review.
|
|
||||||
|
|
||||||
You must not attempt to unfairly manipulate your package's ranking, whether by reviews or any other method.
|
|
||||||
Doing so may result in temporary or permanent suspension from ContentDB.
|
|
||||||
|
|
||||||
|
|
||||||
## 7. Reporting Violations
|
|
||||||
|
|
||||||
Please click "Report" on the package page.
|
|
||||||
|
|
|
@ -1,100 +0,0 @@
|
||||||
title: Privacy Policy
|
|
||||||
|
|
||||||
Last Updated: 2022-01-23
|
|
||||||
([View updates](https://github.com/minetest/contentdb/commits/master/app/flatpages/privacy_policy.md))
|
|
||||||
|
|
||||||
## What Information is Collected
|
|
||||||
|
|
||||||
**All users:**
|
|
||||||
|
|
||||||
* HTTP requests are logged, with the following information:
|
|
||||||
* Time
|
|
||||||
* IP address
|
|
||||||
* Page URL
|
|
||||||
* Response status code
|
|
||||||
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
|
|
||||||
|
|
||||||
**With an account:**
|
|
||||||
|
|
||||||
* Email address
|
|
||||||
* Passwords (hashed and salted using BCrypt)
|
|
||||||
* Profile information, such as website URLs and donation URLs
|
|
||||||
* Comments, threads, and reviews
|
|
||||||
* Audit log actions (such as edits and logins) and their time stamps
|
|
||||||
|
|
||||||
ContentDB collects usernames of content creators from the forums,
|
|
||||||
as this is required to index forum topics.
|
|
||||||
|
|
||||||
Packages, including releases, screenshots, and any meta information,
|
|
||||||
are not considered personal information.
|
|
||||||
|
|
||||||
Please avoid giving other personal information as we do not want it.
|
|
||||||
|
|
||||||
## How this information is used
|
|
||||||
|
|
||||||
* Logged HTTP requests may be used for debugging ContentDB.
|
|
||||||
* Email addresses are used to:
|
|
||||||
* Provide essential system messages, such as password resets and privacy policy updates.
|
|
||||||
* Send notifications - the user may configure this to their needs, including opting out.
|
|
||||||
* The admin may use ContentDB to send emails when they need to contact a user.
|
|
||||||
* Passwords are used to authenticate the user.
|
|
||||||
* The audit log is used to record actions that may be harmful.
|
|
||||||
* Preferred language/locale is used to translate emails and the ContentDB interface.
|
|
||||||
* Other information is displayed as part of ContentDB's service.
|
|
||||||
|
|
||||||
## Who has access
|
|
||||||
|
|
||||||
* Only the admin has access to the HTTP requests.
|
|
||||||
The logs may be shared with others to aid in debugging, but care will be taken to remove any personal information.
|
|
||||||
* Encrypted backups may be shared with selected Minetest staff members (moderators + core devs).
|
|
||||||
The keys and the backups themselves are given to different people,
|
|
||||||
requiring at least two staff members to read a backup.
|
|
||||||
* Email addresses are visible to moderators and the admin.
|
|
||||||
They have access to assist users, and they are not permitted to share email addresses.
|
|
||||||
* Hashing protects passwords from being read whilst stored in the database or in backups.
|
|
||||||
* Profile information is public, including URLs and linked accounts.
|
|
||||||
* The visibility of comments depends on the visibility of threads.
|
|
||||||
They are either public, or visible only to the package author and editors.
|
|
||||||
* The complete audit log is visible to moderators.
|
|
||||||
Users may see their own audit log actions on their account settings page.
|
|
||||||
Owners, maintainers, and editors may be able to see the actions on a package in the future.
|
|
||||||
* Preferred language can only be viewed by this with access to the database or a backup.
|
|
||||||
* We may be required to share information with law enforcement.
|
|
||||||
|
|
||||||
## Location
|
|
||||||
|
|
||||||
The ContentDB production server is currently located in Germany.
|
|
||||||
Backups are stored in the UK.
|
|
||||||
Encrypted backups may be stored in other countries, such as the US or EU.
|
|
||||||
|
|
||||||
By using this service, you give permission for the data to be moved as needed.
|
|
||||||
|
|
||||||
## Period of Retention
|
|
||||||
|
|
||||||
The server uses log rotation, meaning that any logged HTTP requests will be
|
|
||||||
forgotten within a few weeks.
|
|
||||||
|
|
||||||
Usernames may be kept indefinitely, but other user information will be deleted if
|
|
||||||
requested. See below.
|
|
||||||
|
|
||||||
## Removal Requests
|
|
||||||
|
|
||||||
Please [raise a report](https://content.minetest.net/report/?anon=0) if you
|
|
||||||
wish to remove your personal information.
|
|
||||||
|
|
||||||
ContentDB keeps a record of each username and forum topic on the forums,
|
|
||||||
for use in indexing mod/game topics. ContentDB also requires the use of a username
|
|
||||||
to uniquely identify a package. Therefore, an author cannot be removed completely
|
|
||||||
from ContentDB if they have any packages or mod/game topics on the forum.
|
|
||||||
|
|
||||||
If we are unable to remove your account for one of the above reasons, your user
|
|
||||||
account will instead be wiped and deactivated, ending up exactly like an author
|
|
||||||
who has not yet joined ContentDB. All personal information will be removed from the profile,
|
|
||||||
and any comments or threads will be deleted.
|
|
||||||
|
|
||||||
## Future Changes to Privacy Policy
|
|
||||||
|
|
||||||
We will alert any future changes to the privacy policy via email and
|
|
||||||
via notices on the ContentDB website.
|
|
||||||
|
|
||||||
By continuing to use this service, you agree to the privacy policy.
|
|
|
@ -1,24 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2021 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
class LogicError(Exception):
|
|
||||||
def __init__(self, code, message):
|
|
||||||
self.code = code
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return repr("LogicError {}: {}".format(self.code, self.message))
|
|
|
@ -1,188 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2022 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from typing import List, Dict, Optional, Iterator, Iterable
|
|
||||||
|
|
||||||
from app.logic.LogicError import LogicError
|
|
||||||
from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport, db
|
|
||||||
|
|
||||||
"""
|
|
||||||
get_game_support(package):
|
|
||||||
if package is a game:
|
|
||||||
return [ package ]
|
|
||||||
|
|
||||||
for all hard dependencies:
|
|
||||||
support = support AND get_meta_package_support(dep)
|
|
||||||
|
|
||||||
return support
|
|
||||||
|
|
||||||
get_meta_package_support(meta):
|
|
||||||
for package implementing meta package:
|
|
||||||
support = support OR get_game_support(package)
|
|
||||||
|
|
||||||
return support
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
minetest_game_mods = {
|
|
||||||
"beds", "boats", "bucket", "carts", "default", "dungeon_loot", "env_sounds", "fire", "flowers",
|
|
||||||
"give_initial_stuff", "map", "player_api", "sethome", "spawn", "tnt", "walls", "wool",
|
|
||||||
"binoculars", "bones", "butterflies", "creative", "doors", "dye", "farming", "fireflies", "game_commands",
|
|
||||||
"keys", "mtg_craftguide", "screwdriver", "sfinv", "stairs", "vessels", "weather", "xpanes",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
mtg_mod_blacklist = {
|
|
||||||
"repixture", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
|
|
||||||
"survivethedays"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PackageSet:
|
|
||||||
packages: Dict[str, Package]
|
|
||||||
|
|
||||||
def __init__(self, packages: Optional[Iterable[Package]] = None):
|
|
||||||
self.packages = {}
|
|
||||||
if packages:
|
|
||||||
self.update(packages)
|
|
||||||
|
|
||||||
def update(self, packages: Iterable[Package]):
|
|
||||||
for package in packages:
|
|
||||||
key = package.getId()
|
|
||||||
if key not in self.packages:
|
|
||||||
self.packages[key] = package
|
|
||||||
|
|
||||||
def intersection_update(self, other):
|
|
||||||
keys = set(self.packages.keys())
|
|
||||||
keys.difference_update(set(other.packages.keys()))
|
|
||||||
for key in keys:
|
|
||||||
del self.packages[key]
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self.packages)
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return self.packages.values().__iter__()
|
|
||||||
|
|
||||||
|
|
||||||
class GameSupportResolver:
|
|
||||||
checked_packages = set()
|
|
||||||
checked_metapackages = set()
|
|
||||||
resolved_packages: Dict[str, PackageSet] = {}
|
|
||||||
resolved_metapackages: Dict[str, PackageSet] = {}
|
|
||||||
|
|
||||||
def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> PackageSet:
|
|
||||||
print(f"Resolving for {meta.name}", file=sys.stderr)
|
|
||||||
|
|
||||||
key = meta.name
|
|
||||||
if key in self.resolved_metapackages:
|
|
||||||
return self.resolved_metapackages.get(key)
|
|
||||||
|
|
||||||
if key in self.checked_metapackages:
|
|
||||||
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
|
|
||||||
return PackageSet()
|
|
||||||
|
|
||||||
self.checked_metapackages.add(key)
|
|
||||||
|
|
||||||
retval = PackageSet()
|
|
||||||
|
|
||||||
for package in meta.packages:
|
|
||||||
if package.state != PackageState.APPROVED:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if meta.name in minetest_game_mods and package.name in mtg_mod_blacklist:
|
|
||||||
continue
|
|
||||||
|
|
||||||
ret = self.resolve(package, history)
|
|
||||||
if len(ret) == 0:
|
|
||||||
retval = PackageSet()
|
|
||||||
break
|
|
||||||
|
|
||||||
retval.update(ret)
|
|
||||||
|
|
||||||
self.resolved_metapackages[key] = retval
|
|
||||||
return retval
|
|
||||||
|
|
||||||
def resolve(self, package: Package, history: List[str]) -> PackageSet:
|
|
||||||
db.session.merge(package)
|
|
||||||
|
|
||||||
key = package.getId()
|
|
||||||
print(f"Resolving for {key}", file=sys.stderr)
|
|
||||||
|
|
||||||
history = history.copy()
|
|
||||||
history.append(key)
|
|
||||||
|
|
||||||
if package.type == PackageType.GAME:
|
|
||||||
return PackageSet([package])
|
|
||||||
|
|
||||||
if key in self.resolved_packages:
|
|
||||||
return self.resolved_packages.get(key)
|
|
||||||
|
|
||||||
if key in self.checked_packages:
|
|
||||||
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
|
|
||||||
return PackageSet()
|
|
||||||
|
|
||||||
self.checked_packages.add(key)
|
|
||||||
|
|
||||||
if package.type != PackageType.MOD:
|
|
||||||
raise LogicError(500, "Got non-mod")
|
|
||||||
|
|
||||||
retval = PackageSet()
|
|
||||||
|
|
||||||
for dep in package.dependencies.filter_by(optional=False).all():
|
|
||||||
ret = self.resolve_for_meta_package(dep.meta_package, history)
|
|
||||||
if len(ret) == 0:
|
|
||||||
continue
|
|
||||||
elif len(retval) == 0:
|
|
||||||
retval.update(ret)
|
|
||||||
else:
|
|
||||||
retval.intersection_update(ret)
|
|
||||||
if len(retval) == 0:
|
|
||||||
raise LogicError(500, f"Detected game support contradiction, {key} may not be compatible with any games")
|
|
||||||
|
|
||||||
self.resolved_packages[key] = retval
|
|
||||||
return retval
|
|
||||||
|
|
||||||
def update_all(self) -> None:
|
|
||||||
for package in Package.query.filter(Package.type == PackageType.MOD, Package.state != PackageState.DELETED).all():
|
|
||||||
retval = self.resolve(package, [])
|
|
||||||
for game in retval:
|
|
||||||
support = PackageGameSupport(package, game)
|
|
||||||
db.session.add(support)
|
|
||||||
|
|
||||||
def update(self, package: Package) -> None:
|
|
||||||
previous_supported: Dict[str, PackageGameSupport] = {}
|
|
||||||
for support in package.supported_games.all():
|
|
||||||
previous_supported[support.game.getId()] = support
|
|
||||||
|
|
||||||
retval = self.resolve(package, [])
|
|
||||||
for game in retval:
|
|
||||||
assert game
|
|
||||||
|
|
||||||
lookup = previous_supported.pop(game.getId(), None)
|
|
||||||
if lookup is None:
|
|
||||||
support = PackageGameSupport(package, game)
|
|
||||||
db.session.add(support)
|
|
||||||
elif lookup.confidence == 0:
|
|
||||||
lookup.supports = True
|
|
||||||
db.session.merge(lookup)
|
|
||||||
|
|
||||||
for game, support in previous_supported.items():
|
|
||||||
if support.confidence == 0:
|
|
||||||
db.session.remove(support)
|
|
|
@ -1,196 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2021 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import re
|
|
||||||
import validators
|
|
||||||
from flask_babel import lazy_gettext
|
|
||||||
|
|
||||||
from app.logic.LogicError import LogicError
|
|
||||||
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
|
|
||||||
License, UserRank, PackageDevState
|
|
||||||
from app.utils import addAuditLog
|
|
||||||
from app.utils.url import clean_youtube_url
|
|
||||||
|
|
||||||
|
|
||||||
def check(cond: bool, msg: str):
|
|
||||||
if not cond:
|
|
||||||
raise LogicError(400, msg)
|
|
||||||
|
|
||||||
|
|
||||||
def get_license(name):
|
|
||||||
if type(name) == License:
|
|
||||||
return name
|
|
||||||
|
|
||||||
license = License.query.filter(License.name.ilike(name)).first()
|
|
||||||
if license is None:
|
|
||||||
raise LogicError(400, "Unknown license " + name)
|
|
||||||
return license
|
|
||||||
|
|
||||||
|
|
||||||
name_re = re.compile("^[a-z0-9_]+$")
|
|
||||||
|
|
||||||
AnyType = "?"
|
|
||||||
ALLOWED_FIELDS = {
|
|
||||||
"type": AnyType,
|
|
||||||
"title": str,
|
|
||||||
"name": str,
|
|
||||||
"short_description": str,
|
|
||||||
"short_desc": str,
|
|
||||||
"dev_state": AnyType,
|
|
||||||
"tags": list,
|
|
||||||
"content_warnings": list,
|
|
||||||
"license": AnyType,
|
|
||||||
"media_license": AnyType,
|
|
||||||
"long_description": str,
|
|
||||||
"desc": str,
|
|
||||||
"repo": str,
|
|
||||||
"website": str,
|
|
||||||
"issue_tracker": str,
|
|
||||||
"issueTracker": str,
|
|
||||||
"forums": int,
|
|
||||||
"video_url": str,
|
|
||||||
}
|
|
||||||
|
|
||||||
ALIASES = {
|
|
||||||
"short_description": "short_desc",
|
|
||||||
"issue_tracker": "issueTracker",
|
|
||||||
"long_description": "desc"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def is_int(val):
|
|
||||||
try:
|
|
||||||
int(val)
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def validate(data: dict):
|
|
||||||
for key, value in data.items():
|
|
||||||
if value is not None:
|
|
||||||
typ = ALLOWED_FIELDS.get(key)
|
|
||||||
check(typ is not None, key + " is not a known field")
|
|
||||||
if typ != AnyType:
|
|
||||||
check(isinstance(value, typ), key + " must be a " + typ.__name__)
|
|
||||||
|
|
||||||
if "name" in data:
|
|
||||||
name = data["name"]
|
|
||||||
check(isinstance(name, str), "Name must be a string")
|
|
||||||
check(bool(name_re.match(name)),
|
|
||||||
lazy_gettext("Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)"))
|
|
||||||
|
|
||||||
for key in ["repo", "website", "issue_tracker", "issueTracker"]:
|
|
||||||
value = data.get(key)
|
|
||||||
if value is not None:
|
|
||||||
check(value.startswith("http://") or value.startswith("https://"),
|
|
||||||
key + " must start with http:// or https://")
|
|
||||||
|
|
||||||
check(validators.url(value, public=True), key + " must be a valid URL")
|
|
||||||
|
|
||||||
|
|
||||||
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
|
|
||||||
reason: str = None):
|
|
||||||
if not package.checkPerm(user, Permission.EDIT_PACKAGE):
|
|
||||||
raise LogicError(403, lazy_gettext("You do not have permission to edit this package"))
|
|
||||||
|
|
||||||
if "name" in data and package.name != data["name"] and \
|
|
||||||
not package.checkPerm(user, Permission.CHANGE_NAME):
|
|
||||||
raise LogicError(403, lazy_gettext("You do not have permission to change the package name"))
|
|
||||||
|
|
||||||
for alias, to in ALIASES.items():
|
|
||||||
if alias in data:
|
|
||||||
data[to] = data[alias]
|
|
||||||
|
|
||||||
validate(data)
|
|
||||||
|
|
||||||
if "type" in data:
|
|
||||||
data["type"] = PackageType.coerce(data["type"])
|
|
||||||
|
|
||||||
if "dev_state" in data:
|
|
||||||
data["dev_state"] = PackageDevState.coerce(data["dev_state"])
|
|
||||||
|
|
||||||
if "license" in data:
|
|
||||||
data["license"] = get_license(data["license"])
|
|
||||||
|
|
||||||
if "media_license" in data:
|
|
||||||
data["media_license"] = get_license(data["media_license"])
|
|
||||||
|
|
||||||
if "video_url" in data and data["video_url"] is not None:
|
|
||||||
data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"]
|
|
||||||
if "dQw4w9WgXcQ" in data["video_url"]:
|
|
||||||
raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you")
|
|
||||||
|
|
||||||
for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
|
|
||||||
"repo", "website", "issueTracker", "forums", "video_url"]:
|
|
||||||
if key in data:
|
|
||||||
setattr(package, key, data[key])
|
|
||||||
|
|
||||||
if package.type == PackageType.TXP:
|
|
||||||
package.license = package.media_license
|
|
||||||
|
|
||||||
if was_new and package.type == PackageType.MOD:
|
|
||||||
m = MetaPackage.GetOrCreate(package.name, {})
|
|
||||||
package.provides.append(m)
|
|
||||||
|
|
||||||
if "tags" in data:
|
|
||||||
old_tags = list(package.tags)
|
|
||||||
package.tags.clear()
|
|
||||||
for tag_id in data["tags"]:
|
|
||||||
if is_int(tag_id):
|
|
||||||
tag = Tag.query.get(tag_id)
|
|
||||||
else:
|
|
||||||
tag = Tag.query.filter_by(name=tag_id).first()
|
|
||||||
if tag is None:
|
|
||||||
raise LogicError(400, "Unknown tag: " + tag_id)
|
|
||||||
|
|
||||||
if not was_web and tag.is_protected:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if tag.is_protected and tag not in old_tags and not user.rank.atLeast(UserRank.EDITOR):
|
|
||||||
raise LogicError(400, lazy_gettext("Unable to add protected tag %(title)s to package", title=tag.title))
|
|
||||||
|
|
||||||
package.tags.append(tag)
|
|
||||||
|
|
||||||
if not was_web:
|
|
||||||
for tag in old_tags:
|
|
||||||
if tag.is_protected:
|
|
||||||
package.tags.append(tag)
|
|
||||||
|
|
||||||
if "content_warnings" in data:
|
|
||||||
package.content_warnings.clear()
|
|
||||||
for warning_id in data["content_warnings"]:
|
|
||||||
if is_int(warning_id):
|
|
||||||
package.content_warnings.append(ContentWarning.query.get(warning_id))
|
|
||||||
else:
|
|
||||||
warning = ContentWarning.query.filter_by(name=warning_id).first()
|
|
||||||
if warning is None:
|
|
||||||
raise LogicError(400, "Unknown warning: " + warning_id)
|
|
||||||
package.content_warnings.append(warning)
|
|
||||||
|
|
||||||
if not was_new:
|
|
||||||
if reason is None:
|
|
||||||
msg = "Edited {}".format(package.title)
|
|
||||||
else:
|
|
||||||
msg = "Edited {} ({})".format(package.title, reason)
|
|
||||||
|
|
||||||
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
|
|
||||||
addAuditLog(severity, user, msg, package.getURL("packages.view"), package)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return package
|
|
|
@ -1,98 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import datetime, re
|
|
||||||
|
|
||||||
from celery import uuid
|
|
||||||
from flask_babel import lazy_gettext
|
|
||||||
|
|
||||||
from app.logic.LogicError import LogicError
|
|
||||||
from app.logic.uploads import upload_file
|
|
||||||
from app.models import PackageRelease, db, Permission, User, Package, MinetestRelease
|
|
||||||
from app.tasks.importtasks import makeVCSRelease, checkZipRelease
|
|
||||||
from app.utils import AuditSeverity, addAuditLog, nonEmptyOrNone
|
|
||||||
|
|
||||||
|
|
||||||
def check_can_create_release(user: User, package: Package):
|
|
||||||
if not package.checkPerm(user, Permission.MAKE_RELEASE):
|
|
||||||
raise LogicError(403, lazy_gettext("You do not have permission to make releases"))
|
|
||||||
|
|
||||||
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
|
|
||||||
count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()
|
|
||||||
if count >= 5:
|
|
||||||
raise LogicError(429, lazy_gettext("You've created too many releases for this package in the last 5 minutes, please wait before trying again"))
|
|
||||||
|
|
||||||
|
|
||||||
def do_create_vcs_release(user: User, package: Package, title: str, ref: str,
|
|
||||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None):
|
|
||||||
check_can_create_release(user, package)
|
|
||||||
|
|
||||||
rel = PackageRelease()
|
|
||||||
rel.package = package
|
|
||||||
rel.title = title
|
|
||||||
rel.url = ""
|
|
||||||
rel.task_id = uuid()
|
|
||||||
rel.min_rel = min_v
|
|
||||||
rel.max_rel = max_v
|
|
||||||
db.session.add(rel)
|
|
||||||
|
|
||||||
if reason is None:
|
|
||||||
msg = "Created release {}".format(rel.title)
|
|
||||||
else:
|
|
||||||
msg = "Created release {} ({})".format(rel.title, reason)
|
|
||||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
makeVCSRelease.apply_async((rel.id, nonEmptyOrNone(ref)), task_id=rel.task_id)
|
|
||||||
|
|
||||||
return rel
|
|
||||||
|
|
||||||
|
|
||||||
def do_create_zip_release(user: User, package: Package, title: str, file,
|
|
||||||
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None,
|
|
||||||
commit_hash: str = None):
|
|
||||||
check_can_create_release(user, package)
|
|
||||||
|
|
||||||
if commit_hash:
|
|
||||||
commit_hash = commit_hash.lower()
|
|
||||||
if not (len(commit_hash) == 40 and re.match(r"^[0-9a-f]+$", commit_hash)):
|
|
||||||
raise LogicError(400, lazy_gettext("Invalid commit hash; it must be a 40 character long base16 string"))
|
|
||||||
|
|
||||||
uploaded_url, uploaded_path = upload_file(file, "zip", "a zip file")
|
|
||||||
|
|
||||||
rel = PackageRelease()
|
|
||||||
rel.package = package
|
|
||||||
rel.title = title
|
|
||||||
rel.url = uploaded_url
|
|
||||||
rel.task_id = uuid()
|
|
||||||
rel.commit_hash = commit_hash
|
|
||||||
rel.min_rel = min_v
|
|
||||||
rel.max_rel = max_v
|
|
||||||
db.session.add(rel)
|
|
||||||
|
|
||||||
if reason is None:
|
|
||||||
msg = "Created release {}".format(rel.title)
|
|
||||||
else:
|
|
||||||
msg = "Created release {} ({})".format(rel.title, reason)
|
|
||||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
checkZipRelease.apply_async((rel.id, uploaded_path), task_id=rel.task_id)
|
|
||||||
|
|
||||||
return rel
|
|
|
@ -1,87 +0,0 @@
|
||||||
import datetime, json
|
|
||||||
|
|
||||||
from flask_babel import lazy_gettext
|
|
||||||
|
|
||||||
from app.logic.LogicError import LogicError
|
|
||||||
from app.logic.uploads import upload_file
|
|
||||||
from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db, AuditSeverity
|
|
||||||
from app.utils import addNotification, addAuditLog
|
|
||||||
from app.utils.image import get_image_size
|
|
||||||
|
|
||||||
|
|
||||||
def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, reason: str = None):
|
|
||||||
thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
|
|
||||||
count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count()
|
|
||||||
if count >= 20:
|
|
||||||
raise LogicError(429, lazy_gettext("Too many requests, please wait before trying again"))
|
|
||||||
|
|
||||||
uploaded_url, uploaded_path = upload_file(file, "image", lazy_gettext("a PNG or JPG image file"))
|
|
||||||
|
|
||||||
counter = 1
|
|
||||||
for screenshot in package.screenshots.all():
|
|
||||||
screenshot.order = counter
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
ss = PackageScreenshot()
|
|
||||||
ss.package = package
|
|
||||||
ss.title = title or "Untitled"
|
|
||||||
ss.url = uploaded_url
|
|
||||||
ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT)
|
|
||||||
ss.order = counter
|
|
||||||
ss.width, ss.height = get_image_size(uploaded_path)
|
|
||||||
|
|
||||||
if ss.is_too_small():
|
|
||||||
raise LogicError(429,
|
|
||||||
lazy_gettext("Screenshot is too small, it should be at least %(width)s by %(height)s pixels",
|
|
||||||
width=PackageScreenshot.HARD_MIN_SIZE[0], height=PackageScreenshot.HARD_MIN_SIZE[1]))
|
|
||||||
|
|
||||||
db.session.add(ss)
|
|
||||||
|
|
||||||
if reason is None:
|
|
||||||
msg = "Created screenshot {}".format(ss.title)
|
|
||||||
else:
|
|
||||||
msg = "Created screenshot {} ({})".format(ss.title, reason)
|
|
||||||
|
|
||||||
addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getURL("packages.view"), package)
|
|
||||||
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if is_cover_image:
|
|
||||||
package.cover_image = ss
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return ss
|
|
||||||
|
|
||||||
|
|
||||||
def do_order_screenshots(_user: User, package: Package, order: [any]):
|
|
||||||
lookup = {}
|
|
||||||
for screenshot in package.screenshots.all():
|
|
||||||
lookup[screenshot.id] = screenshot
|
|
||||||
|
|
||||||
counter = 1
|
|
||||||
for ss_id in order:
|
|
||||||
try:
|
|
||||||
lookup[int(ss_id)].order = counter
|
|
||||||
counter += 1
|
|
||||||
except KeyError as e:
|
|
||||||
raise LogicError(400, "Unable to find screenshot with id={}".format(ss_id))
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def do_set_cover_image(_user: User, package: Package, cover_image):
|
|
||||||
try:
|
|
||||||
cover_image = int(cover_image)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
|
|
||||||
|
|
||||||
for screenshot in package.screenshots.all():
|
|
||||||
if screenshot.id == cover_image:
|
|
||||||
package.cover_image = screenshot
|
|
||||||
db.session.commit()
|
|
||||||
return
|
|
||||||
|
|
||||||
raise LogicError(400, "Unable to find screenshot")
|
|
|
@ -1,63 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import imghdr
|
|
||||||
import os
|
|
||||||
|
|
||||||
from flask_babel import lazy_gettext
|
|
||||||
|
|
||||||
from app.logic.LogicError import LogicError
|
|
||||||
from app.models import *
|
|
||||||
from app.utils import randomString
|
|
||||||
|
|
||||||
|
|
||||||
def get_extension(filename):
|
|
||||||
return filename.rsplit(".", 1)[1].lower() if "." in filename else None
|
|
||||||
|
|
||||||
ALLOWED_IMAGES = {"jpeg", "png"}
|
|
||||||
def isAllowedImage(data):
|
|
||||||
return imghdr.what(None, data) in ALLOWED_IMAGES
|
|
||||||
|
|
||||||
def upload_file(file, fileType, fileTypeDesc):
|
|
||||||
if not file or file is None or file.filename == "":
|
|
||||||
raise LogicError(400, "Expected file")
|
|
||||||
|
|
||||||
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
|
|
||||||
|
|
||||||
isImage = False
|
|
||||||
if fileType == "image":
|
|
||||||
allowedExtensions = ["jpg", "jpeg", "png"]
|
|
||||||
isImage = True
|
|
||||||
elif fileType == "zip":
|
|
||||||
allowedExtensions = ["zip"]
|
|
||||||
else:
|
|
||||||
raise Exception("Invalid fileType")
|
|
||||||
|
|
||||||
ext = get_extension(file.filename)
|
|
||||||
if ext is None or not ext in allowedExtensions:
|
|
||||||
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=fileTypeDesc))
|
|
||||||
|
|
||||||
if isImage and not isAllowedImage(file.stream.read()):
|
|
||||||
raise LogicError(400, lazy_gettext("Uploaded image isn't actually an image"))
|
|
||||||
|
|
||||||
file.stream.seek(0)
|
|
||||||
|
|
||||||
filename = randomString(10) + "." + ext
|
|
||||||
filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
|
|
||||||
file.save(filepath)
|
|
||||||
|
|
||||||
return "/uploads/" + filename, filepath
|
|
|
@ -1,7 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
|
from enum import Enum
|
||||||
from app.tasks.emails import send_user_email
|
from app.tasks.emails import sendEmailRaw
|
||||||
|
|
||||||
|
|
||||||
def _has_newline(line):
|
def _has_newline(line):
|
||||||
"""Used by has_bad_header to check for \\r or \\n"""
|
"""Used by has_bad_header to check for \\r or \\n"""
|
||||||
|
@ -35,62 +34,76 @@ class FlaskMailSubjectFormatter(logging.Formatter):
|
||||||
class FlaskMailTextFormatter(logging.Formatter):
|
class FlaskMailTextFormatter(logging.Formatter):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# TODO: hier nog niet tevreden over (vooral logger.error(..., exc_info, stack_info))
|
||||||
class FlaskMailHTMLFormatter(logging.Formatter):
|
class FlaskMailHTMLFormatter(logging.Formatter):
|
||||||
|
pre_template = "<h1>%s</h1><pre>%s</pre>"
|
||||||
def formatException(self, exc_info):
|
def formatException(self, exc_info):
|
||||||
formatted_exception = logging.Handler.formatException(self, exc_info)
|
formatted_exception = logging.Handler.formatException(self, exc_info)
|
||||||
return "<pre>%s</pre>" % formatted_exception
|
return FlaskMailHTMLFormatter.pre_template % ("Exception information", formatted_exception)
|
||||||
def formatStack(self, stack_info):
|
def formatStack(self, stack_info):
|
||||||
return "<pre>%s</pre>" % stack_info
|
return FlaskMailHTMLFormatter.pre_template % ("<h1>Stack information</h1><pre><code>%s</code></pre>", stack_info)
|
||||||
|
|
||||||
|
|
||||||
# see: https://github.com/python/cpython/blob/3.6/Lib/logging/__init__.py (class Handler)
|
# see: https://github.com/python/cpython/blob/3.6/Lib/logging/__init__.py (class Handler)
|
||||||
|
|
||||||
class FlaskMailHandler(logging.Handler):
|
class FlaskMailHandler(logging.Handler):
|
||||||
def __init__(self, send_to, subject_template, level=logging.NOTSET):
|
def __init__(self, mailer, subject_template, level=logging.NOTSET):
|
||||||
logging.Handler.__init__(self, level)
|
logging.Handler.__init__(self, level)
|
||||||
self.send_to = send_to
|
self.mailer = mailer
|
||||||
|
self.send_to = mailer.app.config["MAIL_UTILS_ERROR_SEND_TO"]
|
||||||
self.subject_template = subject_template
|
self.subject_template = subject_template
|
||||||
|
self.html_formatter = None
|
||||||
|
|
||||||
def setFormatter(self, text_fmt):
|
def setFormatter(self, text_fmt, html_fmt=None):
|
||||||
"""
|
"""
|
||||||
Set the formatters for this handler. Provide at least one formatter.
|
Set the formatters for this handler. Provide at least one formatter.
|
||||||
When no text_fmt is provided, no text-part is created for the email body.
|
When no text_fmt is provided, no text-part is created for the email body.
|
||||||
"""
|
"""
|
||||||
assert text_fmt != None, "At least one formatter should be provided"
|
assert (text_fmt, html_fmt) != (None, None), "At least one formatter should be provided"
|
||||||
if type(text_fmt)==str:
|
if type(text_fmt)==str:
|
||||||
text_fmt = FlaskMailTextFormatter(text_fmt)
|
text_fmt = FlaskMailTextFormatter(text_fmt)
|
||||||
self.formatter = text_fmt
|
self.formatter = text_fmt
|
||||||
|
if type(html_fmt)==str:
|
||||||
|
html_fmt = FlaskMailHTMLFormatter(html_fmt)
|
||||||
|
self.html_formatter = html_fmt
|
||||||
|
|
||||||
def getSubject(self, record):
|
def getSubject(self, record):
|
||||||
fmt = FlaskMailSubjectFormatter(self.subject_template)
|
fmt = FlaskMailSubjectFormatter(self.subject_template)
|
||||||
subject = fmt.format(record)
|
subject = fmt.format(record)
|
||||||
# Since templates can cause header problems, and we rather have a incomplete email then an error, we fix this
|
#Since templates can cause header problems, and we rather have a incomplete email then an error, we fix this
|
||||||
if _is_bad_subject(subject):
|
if _is_bad_subject(subject):
|
||||||
subject="FlaskMailHandler log-entry from ContentDB [original subject is replaced, because it would result in a bad header]"
|
subject="FlaskMailHandler log-entry from %s [original subject is replaced, because it would result in a bad header]" % self.mailer.app.name
|
||||||
return subject
|
return subject
|
||||||
|
|
||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
subject = self.getSubject(record)
|
|
||||||
text = self.format(record) if self.formatter else None
|
text = self.format(record) if self.formatter else None
|
||||||
html = "<pre>{}</pre>".format(text)
|
html = self.html_formatter.format(record) if self.html_formatter else None
|
||||||
|
sendEmailRaw.delay(self.send_to, self.getSubject(record), text, html)
|
||||||
if "The recipient has exceeded message rate limit. Try again later" in subject:
|
|
||||||
return
|
|
||||||
|
|
||||||
for email in self.send_to:
|
|
||||||
send_user_email.delay(email, "en", subject, text, html)
|
|
||||||
|
|
||||||
|
|
||||||
def build_handler(app):
|
def register_mail_error_handler(app, mailer):
|
||||||
subject_template = "ContentDB %(message)s (%(module)s > %(funcName)s)"
|
subject_template = "ContentDB crashed (%(module)s > %(funcName)s)"
|
||||||
text_template = ("Message type: %(levelname)s\n"
|
text_template = """
|
||||||
"Location: %(pathname)s:%(lineno)d\n"
|
Message type: %(levelname)s
|
||||||
"Module: %(module)s\n"
|
Location: %(pathname)s:%(lineno)d
|
||||||
"Function: %(funcName)s\n"
|
Module: %(module)s
|
||||||
"Time: %(asctime)s\n"
|
Function: %(funcName)s
|
||||||
"Message: %(message)s\n\n")
|
Time: %(asctime)s
|
||||||
|
Message:
|
||||||
|
%(message)s"""
|
||||||
|
html_template = """
|
||||||
|
<style>th { text-align: right}</style><table>
|
||||||
|
<tr><th>Message type:</th><td>%(levelname)s</td></tr>
|
||||||
|
<tr> <th>Location:</th><td>%(pathname)s:%(lineno)d</td></tr>
|
||||||
|
<tr> <th>Module:</th><td>%(module)s</td></tr>
|
||||||
|
<tr> <th>Function:</th><td>%(funcName)s</td></tr>
|
||||||
|
<tr> <th>Time:</th><td>%(asctime)s</td></tr>
|
||||||
|
</table>
|
||||||
|
<h2>Message</h2>
|
||||||
|
<pre><code>%(message)s</code></pre>"""
|
||||||
|
|
||||||
mail_handler = FlaskMailHandler(app.config["MAIL_UTILS_ERROR_SEND_TO"], subject_template)
|
import logging
|
||||||
|
mail_handler = FlaskMailHandler(mailer, subject_template)
|
||||||
mail_handler.setLevel(logging.ERROR)
|
mail_handler.setLevel(logging.ERROR)
|
||||||
mail_handler.setFormatter(text_template)
|
mail_handler.setFormatter(text_template, html_template)
|
||||||
return mail_handler
|
app.logger.addHandler(mail_handler)
|
||||||
|
|
204
app/markdown.py
|
@ -1,179 +1,63 @@
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
from bleach import Cleaner
|
|
||||||
from bleach.linkifier import LinkifyFilter
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from markdown import Markdown
|
from markdown import Markdown
|
||||||
from flask import Markup, url_for
|
from flask import Markup
|
||||||
from markdown.extensions import Extension
|
|
||||||
from markdown.inlinepatterns import SimpleTagInlineProcessor
|
|
||||||
from markdown.inlinepatterns import Pattern
|
|
||||||
from xml.etree import ElementTree
|
|
||||||
|
|
||||||
# Based on
|
# Whitelist source: MIT
|
||||||
# https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py
|
|
||||||
#
|
#
|
||||||
# License: MIT
|
# https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
Default whitelist of allowed HTML tags. Any other HTML tags will be escaped or
|
||||||
|
stripped from the text. This applies to the html output that Markdown produces.
|
||||||
|
"""
|
||||||
ALLOWED_TAGS = [
|
ALLOWED_TAGS = [
|
||||||
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
|
'ul',
|
||||||
"ul", "ol", "li",
|
'ol',
|
||||||
"p",
|
'li',
|
||||||
"br",
|
'p',
|
||||||
"pre",
|
'pre',
|
||||||
"code",
|
'code',
|
||||||
"blockquote",
|
'blockquote',
|
||||||
"strong",
|
'h1',
|
||||||
"em",
|
'h2',
|
||||||
"a",
|
'h3',
|
||||||
"img",
|
'h4',
|
||||||
"table", "thead", "tbody", "tr", "th", "td",
|
'h5',
|
||||||
"div", "span", "del", "s",
|
'h6',
|
||||||
|
'hr',
|
||||||
|
'br',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'a',
|
||||||
|
'img'
|
||||||
]
|
]
|
||||||
|
|
||||||
ALLOWED_CSS = [
|
"""
|
||||||
"highlight", "codehilite",
|
Default whitelist of attributes. It allows the href and title attributes for <a>
|
||||||
"hll", "c", "err", "g", "k", "l", "n", "o", "x", "p", "ch", "cm", "cp", "cpf", "c1", "cs",
|
tags and the src, title and alt attributes for <img> tags. Any other attribute
|
||||||
"gd", "ge", "gr", "gh", "gi", "go", "gp", "gs", "gu", "gt", "kc", "kd", "kn", "kp", "kr",
|
will be stripped from its tag.
|
||||||
"kt", "ld", "m", "s", "na", "nb", "nc", "no", "nd", "ni", "ne", "nf", "nl", "nn", "nx",
|
"""
|
||||||
"py", "nt", "nv", "ow", "w", "mb", "mf", "mh", "mi", "mo", "sa", "sb", "sc", "dl", "sd",
|
|
||||||
"s2", "se", "sh", "si", "sx", "sr", "s1", "ss", "bp", "fm", "vc", "vg", "vi", "vm", "il",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def allow_class(_tag, name, value):
|
|
||||||
return name == "class" and value in ALLOWED_CSS
|
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_ATTRIBUTES = {
|
ALLOWED_ATTRIBUTES = {
|
||||||
"h1": ["id"],
|
'a': ['href', 'title'],
|
||||||
"h2": ["id"],
|
'img': ['src', 'title', 'alt']
|
||||||
"h3": ["id"],
|
|
||||||
"h4": ["id"],
|
|
||||||
"a": ["href", "title", "data-username"],
|
|
||||||
"img": ["src", "title", "alt"],
|
|
||||||
"code": allow_class,
|
|
||||||
"div": allow_class,
|
|
||||||
"span": allow_class,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
|
"""
|
||||||
|
If you allow tags that have attributes containing a URI value
|
||||||
|
(like the href attribute of an anchor tag,) you may want to adapt
|
||||||
|
the accepted protocols. The default list only allows http, https and mailto.
|
||||||
|
"""
|
||||||
|
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
|
||||||
|
|
||||||
md = None
|
|
||||||
|
|
||||||
|
md = Markdown(extensions=["fenced_code"], output_format="html5")
|
||||||
|
|
||||||
def render_markdown(source):
|
def render_markdown(source):
|
||||||
html = md.convert(source)
|
return bleach.clean(md.convert(source), \
|
||||||
|
tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES, \
|
||||||
cleaner = Cleaner(
|
styles=[], protocols=ALLOWED_PROTOCOLS)
|
||||||
tags=ALLOWED_TAGS,
|
|
||||||
attributes=ALLOWED_ATTRIBUTES,
|
|
||||||
protocols=ALLOWED_PROTOCOLS,
|
|
||||||
filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)])
|
|
||||||
return cleaner.clean(html)
|
|
||||||
|
|
||||||
|
|
||||||
class DelInsExtension(Extension):
|
|
||||||
def extendMarkdown(self, md):
|
|
||||||
del_proc = SimpleTagInlineProcessor(r"(\~\~)(.+?)(\~\~)", "del")
|
|
||||||
md.inlinePatterns.register(del_proc, "del", 200)
|
|
||||||
|
|
||||||
ins_proc = SimpleTagInlineProcessor(r"(\+\+)(.+?)(\+\+)", "ins")
|
|
||||||
md.inlinePatterns.register(ins_proc, "ins", 200)
|
|
||||||
|
|
||||||
|
|
||||||
RE_PARTS = dict(
|
|
||||||
USER=r"[A-Za-z0-9._-]*\b",
|
|
||||||
REPO=r"[A-Za-z0-9_]+\b"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MentionPattern(Pattern):
|
|
||||||
ANCESTOR_EXCLUDES = ("a",)
|
|
||||||
|
|
||||||
def __init__(self, config, md):
|
|
||||||
MENTION_RE = r"(@({USER})(?:\/({REPO}))?)".format(**RE_PARTS)
|
|
||||||
super(MentionPattern, self).__init__(MENTION_RE, md)
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
def handleMatch(self, m):
|
|
||||||
from app.models import User
|
|
||||||
|
|
||||||
label = m.group(2)
|
|
||||||
user = m.group(3)
|
|
||||||
package_name = m.group(4)
|
|
||||||
if package_name:
|
|
||||||
el = ElementTree.Element("a")
|
|
||||||
el.text = label
|
|
||||||
el.set("href", url_for("packages.view", author=user, name=package_name))
|
|
||||||
return el
|
|
||||||
else:
|
|
||||||
if User.query.filter_by(username=user).count() == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
el = ElementTree.Element("a")
|
|
||||||
el.text = label
|
|
||||||
el.set("href", url_for("users.profile", username=user))
|
|
||||||
el.set("data-username", user)
|
|
||||||
return el
|
|
||||||
|
|
||||||
|
|
||||||
class MentionExtension(Extension):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(MentionExtension, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def extendMarkdown(self, md):
|
|
||||||
md.ESCAPED_CHARS.append("@")
|
|
||||||
md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20)
|
|
||||||
|
|
||||||
|
|
||||||
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "codehilite", "toc", DelInsExtension(), MentionExtension()]
|
|
||||||
MARKDOWN_EXTENSION_CONFIG = {
|
|
||||||
"fenced_code": {},
|
|
||||||
"tables": {},
|
|
||||||
"codehilite": {
|
|
||||||
"guess_lang": False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def init_markdown(app):
|
|
||||||
global md
|
|
||||||
|
|
||||||
md = Markdown(extensions=MARKDOWN_EXTENSIONS,
|
|
||||||
extension_configs=MARKDOWN_EXTENSION_CONFIG,
|
|
||||||
output_format="html5")
|
|
||||||
|
|
||||||
|
def init_app(app):
|
||||||
@app.template_filter()
|
@app.template_filter()
|
||||||
def markdown(source):
|
def markdown(source):
|
||||||
return Markup(render_markdown(source))
|
return Markup(render_markdown(source))
|
||||||
|
|
||||||
|
|
||||||
def get_headings(html: str):
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
headings = soup.find_all(["h1", "h2", "h3"])
|
|
||||||
|
|
||||||
root = []
|
|
||||||
stack = []
|
|
||||||
for heading in headings:
|
|
||||||
this = {"link": heading.get("id") or "", "text": heading.text, "children": []}
|
|
||||||
this_level = int(heading.name[1:]) - 1
|
|
||||||
|
|
||||||
while this_level <= len(stack):
|
|
||||||
stack.pop()
|
|
||||||
|
|
||||||
if len(stack) > 0:
|
|
||||||
stack[-1]["children"].append(this)
|
|
||||||
else:
|
|
||||||
root.append(this)
|
|
||||||
|
|
||||||
stack.append(this)
|
|
||||||
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_mentions(html: str) -> set:
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
links = soup.select("a[data-username]")
|
|
||||||
return set([x.get("data-username") for x in links])
|
|
||||||
|
|
|
@ -1,177 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
from flask_migrate import Migrate
|
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
|
||||||
from sqlalchemy_searchable import make_searchable
|
|
||||||
|
|
||||||
from app import app
|
|
||||||
|
|
||||||
# Initialise database
|
|
||||||
|
|
||||||
db = SQLAlchemy(app)
|
|
||||||
migrate = Migrate(app, db)
|
|
||||||
make_searchable(db.metadata)
|
|
||||||
|
|
||||||
|
|
||||||
from .packages import *
|
|
||||||
from .users import *
|
|
||||||
from .threads import *
|
|
||||||
|
|
||||||
|
|
||||||
class APIToken(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
access_token = db.Column(db.String(34), unique=True, nullable=False)
|
|
||||||
|
|
||||||
name = db.Column(db.String(100), nullable=False)
|
|
||||||
|
|
||||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
|
||||||
owner = db.relationship("User", foreign_keys=[owner_id], back_populates="tokens")
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
|
||||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="tokens")
|
|
||||||
|
|
||||||
def canOperateOnPackage(self, package):
|
|
||||||
if self.package and self.package != package:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return package.author == self.owner
|
|
||||||
|
|
||||||
|
|
||||||
class AuditSeverity(enum.Enum):
|
|
||||||
NORMAL = 0 # Normal user changes
|
|
||||||
USER = 1 # Security user changes
|
|
||||||
EDITOR = 2 # Editor changes
|
|
||||||
MODERATION = 3 # Destructive / moderator changes
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def getTitle(self):
|
|
||||||
return self.name.replace("_", " ").title()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def choices(cls):
|
|
||||||
return [(choice, choice.getTitle()) for choice in cls]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def coerce(cls, item):
|
|
||||||
return item if type(item) == AuditSeverity else AuditSeverity[item.upper()]
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogEntry(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
|
||||||
causer = db.relationship("User", foreign_keys=[causer_id], back_populates="audit_log_entries")
|
|
||||||
|
|
||||||
severity = db.Column(db.Enum(AuditSeverity), nullable=False)
|
|
||||||
|
|
||||||
title = db.Column(db.String(100), nullable=False)
|
|
||||||
url = db.Column(db.String(200), nullable=True)
|
|
||||||
|
|
||||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
|
||||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="audit_log_entries")
|
|
||||||
|
|
||||||
description = db.Column(db.Text, nullable=True, default=None)
|
|
||||||
|
|
||||||
def __init__(self, causer, severity, title, url, package=None, description=None):
|
|
||||||
if len(title) > 100:
|
|
||||||
title = title[:99] + "…"
|
|
||||||
|
|
||||||
self.causer = causer
|
|
||||||
self.severity = severity
|
|
||||||
self.title = title
|
|
||||||
self.url = url
|
|
||||||
self.package = package
|
|
||||||
self.description = description
|
|
||||||
|
|
||||||
|
|
||||||
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com",
|
|
||||||
"minetest.net", "dropboxusercontent.com", "4shared.com",
|
|
||||||
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net",
|
|
||||||
"imageshack.com", "imgur.com"]
|
|
||||||
|
|
||||||
|
|
||||||
class ForumTopic(db.Model):
|
|
||||||
topic_id = db.Column(db.Integer, primary_key=True, autoincrement=False)
|
|
||||||
|
|
||||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
|
||||||
author = db.relationship("User", back_populates="forum_topics")
|
|
||||||
|
|
||||||
wip = db.Column(db.Boolean, default=False, nullable=False)
|
|
||||||
discarded = db.Column(db.Boolean, default=False, nullable=False)
|
|
||||||
|
|
||||||
type = db.Column(db.Enum(PackageType), nullable=False)
|
|
||||||
title = db.Column(db.String(200), nullable=False)
|
|
||||||
name = db.Column(db.String(30), nullable=True)
|
|
||||||
link = db.Column(db.String(200), nullable=True)
|
|
||||||
|
|
||||||
posts = db.Column(db.Integer, nullable=False)
|
|
||||||
views = db.Column(db.Integer, nullable=False)
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
def getRepoURL(self):
|
|
||||||
if self.link is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for item in REPO_BLACKLIST:
|
|
||||||
if item in self.link:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self.link.replace("repo.or.cz/w/", "repo.or.cz/")
|
|
||||||
|
|
||||||
def getAsDictionary(self):
|
|
||||||
return {
|
|
||||||
"author": self.author.username,
|
|
||||||
"name": self.name,
|
|
||||||
"type": self.type.toName(),
|
|
||||||
"title": self.title,
|
|
||||||
"id": self.topic_id,
|
|
||||||
"link": self.link,
|
|
||||||
"posts": self.posts,
|
|
||||||
"views": self.views,
|
|
||||||
"is_wip": self.wip,
|
|
||||||
"discarded": self.discarded,
|
|
||||||
"created_at": self.created_at.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def checkPerm(self, user, perm):
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if type(perm) == str:
|
|
||||||
perm = Permission[perm]
|
|
||||||
elif type(perm) != Permission:
|
|
||||||
raise Exception("Unknown permission given to ForumTopic.checkPerm()")
|
|
||||||
|
|
||||||
if perm == Permission.TOPIC_DISCARD:
|
|
||||||
return self.author == user or user.rank.atLeast(UserRank.EDITOR)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise Exception("Permission {} is not related to topics".format(perm.name))
|
|
||||||
|
|
||||||
|
|
||||||
if app.config.get("LOG_SQL"):
|
|
||||||
import logging
|
|
||||||
logging.basicConfig()
|
|
||||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
|
|
@ -1,240 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
from typing import Tuple, List
|
|
||||||
|
|
||||||
from flask import url_for
|
|
||||||
|
|
||||||
from . import db
|
|
||||||
from .users import Permission, UserRank
|
|
||||||
from .packages import Package
|
|
||||||
|
|
||||||
watchers = db.Table("watchers",
|
|
||||||
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
|
|
||||||
db.Column("thread_id", db.Integer, db.ForeignKey("thread.id"), primary_key=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Thread(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
|
|
||||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
|
||||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="threads")
|
|
||||||
|
|
||||||
is_review_thread = db.relationship("Package", foreign_keys=[Package.review_thread_id], back_populates="review_thread")
|
|
||||||
|
|
||||||
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), nullable=True)
|
|
||||||
review = db.relationship("PackageReview", foreign_keys=[review_id], cascade="all, delete")
|
|
||||||
|
|
||||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
|
||||||
author = db.relationship("User", back_populates="threads", foreign_keys=[author_id])
|
|
||||||
|
|
||||||
title = db.Column(db.String(100), nullable=False)
|
|
||||||
private = db.Column(db.Boolean, server_default="0", nullable=False)
|
|
||||||
|
|
||||||
locked = db.Column(db.Boolean, server_default="0", nullable=False)
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
replies = db.relationship("ThreadReply", back_populates="thread", lazy="dynamic",
|
|
||||||
order_by=db.asc("thread_reply_id"), cascade="all, delete, delete-orphan")
|
|
||||||
|
|
||||||
watchers = db.relationship("User", secondary=watchers, backref="watching")
|
|
||||||
|
|
||||||
def get_description(self):
|
|
||||||
comment = self.replies[0].comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ")
|
|
||||||
if len(comment) > 100:
|
|
||||||
return comment[:97] + "..."
|
|
||||||
else:
|
|
||||||
return comment
|
|
||||||
|
|
||||||
def getViewURL(self, absolute=False):
|
|
||||||
if absolute:
|
|
||||||
from ..utils import abs_url_for
|
|
||||||
return abs_url_for("threads.view", id=self.id)
|
|
||||||
else:
|
|
||||||
return url_for("threads.view", id=self.id, _external=False)
|
|
||||||
|
|
||||||
def getSubscribeURL(self):
|
|
||||||
return url_for("threads.subscribe", id=self.id)
|
|
||||||
|
|
||||||
def getUnsubscribeURL(self):
|
|
||||||
return url_for("threads.unsubscribe", id=self.id)
|
|
||||||
|
|
||||||
def checkPerm(self, user, perm):
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return perm == Permission.SEE_THREAD and not self.private
|
|
||||||
|
|
||||||
if type(perm) == str:
|
|
||||||
perm = Permission[perm]
|
|
||||||
elif type(perm) != Permission:
|
|
||||||
raise Exception("Unknown permission given to Thread.checkPerm()")
|
|
||||||
|
|
||||||
isMaintainer = user == self.author or (self.package is not None and self.package.author == user)
|
|
||||||
if self.package:
|
|
||||||
isMaintainer = isMaintainer or user in self.package.maintainers
|
|
||||||
|
|
||||||
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER)
|
|
||||||
|
|
||||||
if perm == Permission.SEE_THREAD:
|
|
||||||
return canSee
|
|
||||||
|
|
||||||
elif perm == Permission.COMMENT_THREAD:
|
|
||||||
return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR))
|
|
||||||
|
|
||||||
elif perm == Permission.LOCK_THREAD:
|
|
||||||
return user.rank.atLeast(UserRank.MODERATOR)
|
|
||||||
|
|
||||||
elif perm == Permission.DELETE_THREAD:
|
|
||||||
from app.utils.models import get_system_user
|
|
||||||
return (self.author == get_system_user() and self.package and
|
|
||||||
user in self.package.maintainers) or user.rank.atLeast(UserRank.MODERATOR)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise Exception("Permission {} is not related to threads".format(perm.name))
|
|
||||||
|
|
||||||
def get_latest_reply(self):
|
|
||||||
return ThreadReply.query.filter_by(thread_id=self.id).order_by(db.desc(ThreadReply.id)).first()
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadReply(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
|
|
||||||
thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
|
|
||||||
thread = db.relationship("Thread", back_populates="replies", foreign_keys=[thread_id])
|
|
||||||
|
|
||||||
comment = db.Column(db.String(2000), nullable=False)
|
|
||||||
|
|
||||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
|
||||||
author = db.relationship("User", back_populates="replies", foreign_keys=[author_id])
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
def get_url(self):
|
|
||||||
return url_for('threads.view', id=self.thread.id) + "#reply-" + str(self.id)
|
|
||||||
|
|
||||||
def checkPerm(self, user, perm):
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if type(perm) == str:
|
|
||||||
perm = Permission[perm]
|
|
||||||
elif type(perm) != Permission:
|
|
||||||
raise Exception("Unknown permission given to ThreadReply.checkPerm()")
|
|
||||||
|
|
||||||
if perm == Permission.EDIT_REPLY:
|
|
||||||
return user.rank.atLeast(UserRank.MEMBER if user == self.author else UserRank.MODERATOR) and not self.thread.locked
|
|
||||||
|
|
||||||
elif perm == Permission.DELETE_REPLY:
|
|
||||||
return user.rank.atLeast(UserRank.MODERATOR) and self.thread.replies[0] != self
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise Exception("Permission {} is not related to threads".format(perm.name))
|
|
||||||
|
|
||||||
|
|
||||||
class PackageReview(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
|
|
||||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
|
||||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="reviews")
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
|
||||||
author = db.relationship("User", foreign_keys=[author_id], back_populates="reviews")
|
|
||||||
|
|
||||||
recommends = db.Column(db.Boolean, nullable=False)
|
|
||||||
|
|
||||||
thread = db.relationship("Thread", uselist=False, back_populates="review")
|
|
||||||
votes = db.relationship("PackageReviewVote", back_populates="review", cascade="all, delete, delete-orphan")
|
|
||||||
|
|
||||||
score = db.Column(db.Integer, nullable=False, default=1)
|
|
||||||
|
|
||||||
def get_totals(self, current_user = None) -> Tuple[int,int,bool]:
|
|
||||||
votes: List[PackageReviewVote] = self.votes
|
|
||||||
pos = sum([ 1 for vote in votes if vote.is_positive ])
|
|
||||||
neg = sum([ 1 for vote in votes if not vote.is_positive])
|
|
||||||
user_vote = next(filter(lambda vote: vote.user == current_user, votes), None)
|
|
||||||
return pos, neg, user_vote.is_positive if user_vote else None
|
|
||||||
|
|
||||||
def getAsDictionary(self, include_package=False):
|
|
||||||
pos, neg, _user = self.get_totals()
|
|
||||||
ret = {
|
|
||||||
"is_positive": self.recommends,
|
|
||||||
"user": {
|
|
||||||
"username": self.author.username,
|
|
||||||
"display_name": self.author.display_name,
|
|
||||||
},
|
|
||||||
"created_at": self.created_at.isoformat(),
|
|
||||||
"votes": {
|
|
||||||
"helpful": pos,
|
|
||||||
"unhelpful": neg,
|
|
||||||
},
|
|
||||||
"title": self.thread.title,
|
|
||||||
"comment": self.thread.replies[0].comment,
|
|
||||||
}
|
|
||||||
if include_package:
|
|
||||||
ret["package"] = self.package.getAsDictionaryKey()
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def asSign(self):
|
|
||||||
return 1 if self.recommends else -1
|
|
||||||
|
|
||||||
def getEditURL(self):
|
|
||||||
return self.package.getURL("packages.review")
|
|
||||||
|
|
||||||
def getDeleteURL(self):
|
|
||||||
return url_for("packages.delete_review",
|
|
||||||
author=self.package.author.username,
|
|
||||||
name=self.package.name,
|
|
||||||
reviewer=self.author.username)
|
|
||||||
|
|
||||||
def getVoteUrl(self, next_url=None):
|
|
||||||
return url_for("packages.review_vote",
|
|
||||||
author=self.package.author.username,
|
|
||||||
name=self.package.name,
|
|
||||||
review_id=self.id,
|
|
||||||
r=next_url)
|
|
||||||
|
|
||||||
def update_score(self):
|
|
||||||
(pos, neg, _) = self.get_totals()
|
|
||||||
self.score = 3 * (pos - neg) + 1
|
|
||||||
|
|
||||||
def checkPerm(self, user, perm):
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if type(perm) == str:
|
|
||||||
perm = Permission[perm]
|
|
||||||
elif type(perm) != Permission:
|
|
||||||
raise Exception("Unknown permission given to PackageReview.checkPerm()")
|
|
||||||
|
|
||||||
if perm == Permission.DELETE_REVIEW:
|
|
||||||
return user == self.author or user.rank.atLeast(UserRank.MODERATOR)
|
|
||||||
else:
|
|
||||||
raise Exception("Permission {} is not related to reviews".format(perm.name))
|
|
||||||
|
|
||||||
|
|
||||||
class PackageReviewVote(db.Model):
|
|
||||||
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True)
|
|
||||||
review = db.relationship("PackageReview", foreign_keys=[review_id], back_populates="votes")
|
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True)
|
|
||||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="review_votes")
|
|
||||||
|
|
||||||
is_positive = db.Column(db.Boolean, nullable=False)
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
|
@ -1,479 +0,0 @@
|
||||||
# ContentDB
|
|
||||||
# Copyright (C) 2018-21 rubenwardy
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import enum
|
|
||||||
|
|
||||||
from flask_login import UserMixin
|
|
||||||
from sqlalchemy import desc, text
|
|
||||||
|
|
||||||
from app import gravatar
|
|
||||||
from . import db
|
|
||||||
|
|
||||||
|
|
||||||
class UserRank(enum.Enum):
|
|
||||||
BANNED = 0
|
|
||||||
NOT_JOINED = 1
|
|
||||||
NEW_MEMBER = 2
|
|
||||||
MEMBER = 3
|
|
||||||
TRUSTED_MEMBER = 4
|
|
||||||
APPROVER = 5
|
|
||||||
EDITOR = 6
|
|
||||||
BOT = 7
|
|
||||||
MODERATOR = 8
|
|
||||||
ADMIN = 9
|
|
||||||
|
|
||||||
def atLeast(self, min):
|
|
||||||
return self.value >= min.value
|
|
||||||
|
|
||||||
def getTitle(self):
|
|
||||||
return self.name.replace("_", " ").title()
|
|
||||||
|
|
||||||
def toName(self):
|
|
||||||
return self.name.lower()
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def choices(cls):
|
|
||||||
return [(choice, choice.getTitle()) for choice in cls]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def coerce(cls, item):
|
|
||||||
return item if type(item) == UserRank else UserRank[item.upper()]
|
|
||||||
|
|
||||||
|
|
||||||
class Permission(enum.Enum):
|
|
||||||
EDIT_PACKAGE = "EDIT_PACKAGE"
|
|
||||||
DELETE_PACKAGE = "DELETE_PACKAGE"
|
|
||||||
CHANGE_AUTHOR = "CHANGE_AUTHOR"
|
|
||||||
CHANGE_NAME = "CHANGE_NAME"
|
|
||||||
MAKE_RELEASE = "MAKE_RELEASE"
|
|
||||||
DELETE_RELEASE = "DELETE_RELEASE"
|
|
||||||
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
|
|
||||||
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
|
|
||||||
APPROVE_RELEASE = "APPROVE_RELEASE"
|
|
||||||
APPROVE_NEW = "APPROVE_NEW"
|
|
||||||
EDIT_TAGS = "EDIT_TAGS"
|
|
||||||
CREATE_TAG = "CREATE_TAG"
|
|
||||||
CHANGE_RELEASE_URL = "CHANGE_RELEASE_URL"
|
|
||||||
CHANGE_USERNAMES = "CHANGE_USERNAMES"
|
|
||||||
CHANGE_RANK = "CHANGE_RANK"
|
|
||||||
CHANGE_EMAIL = "CHANGE_EMAIL"
|
|
||||||
SEE_THREAD = "SEE_THREAD"
|
|
||||||
CREATE_THREAD = "CREATE_THREAD"
|
|
||||||
COMMENT_THREAD = "COMMENT_THREAD"
|
|
||||||
LOCK_THREAD = "LOCK_THREAD"
|
|
||||||
DELETE_THREAD = "DELETE_THREAD"
|
|
||||||
DELETE_REPLY = "DELETE_REPLY"
|
|
||||||
EDIT_REPLY = "EDIT_REPLY"
|
|
||||||
UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE"
|
|
||||||
TOPIC_DISCARD = "TOPIC_DISCARD"
|
|
||||||
CREATE_TOKEN = "CREATE_TOKEN"
|
|
||||||
EDIT_MAINTAINERS = "EDIT_MAINTAINERS"
|
|
||||||
DELETE_REVIEW = "DELETE_REVIEW"
|
|
||||||
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
|
|
||||||
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
|
|
||||||
|
|
||||||
# Only return true if the permission is valid for *all* contexts
|
|
||||||
# See Package.checkPerm for package-specific contexts
|
|
||||||
def check(self, user):
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self == Permission.APPROVE_NEW or \
|
|
||||||
self == Permission.APPROVE_RELEASE or \
|
|
||||||
self == Permission.APPROVE_SCREENSHOT or \
|
|
||||||
self == Permission.SEE_THREAD:
|
|
||||||
return user.rank.atLeast(UserRank.APPROVER)
|
|
||||||
|
|
||||||
elif self == Permission.EDIT_TAGS or self == Permission.CREATE_TAG:
|
|
||||||
return user.rank.atLeast(UserRank.EDITOR)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def checkPerm(user, perm):
|
|
||||||
if type(perm) == str:
|
|
||||||
perm = Permission[perm]
|
|
||||||
elif type(perm) != Permission:
|
|
||||||
raise Exception("Unknown permission given to Permission.check")
|
|
||||||
|
|
||||||
return perm.check(user)
|
|
||||||
|
|
||||||
|
|
||||||
def display_name_default(context):
|
|
||||||
return context.get_current_parameters()["username"]
|
|
||||||
|
|
||||||
|
|
||||||
class User(db.Model, UserMixin):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=True, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
# User authentication information
|
|
||||||
username = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True)
|
|
||||||
password = db.Column(db.String(255), nullable=True, server_default=None)
|
|
||||||
reset_password_token = db.Column(db.String(100), nullable=False, server_default="")
|
|
||||||
|
|
||||||
def get_id(self):
|
|
||||||
return self.username
|
|
||||||
|
|
||||||
rank = db.Column(db.Enum(UserRank), nullable=False)
|
|
||||||
|
|
||||||
# Account linking
|
|
||||||
github_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
|
|
||||||
forums_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
|
|
||||||
|
|
||||||
# Access token for webhook setup
|
|
||||||
github_access_token = db.Column(db.String(50), nullable=True, server_default=None)
|
|
||||||
|
|
||||||
# User email information
|
|
||||||
email = db.Column(db.String(255), nullable=True, unique=True)
|
|
||||||
email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None)
|
|
||||||
|
|
||||||
locale = db.Column(db.String(10), nullable=True, default=None)
|
|
||||||
|
|
||||||
# User information
|
|
||||||
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
|
|
||||||
is_active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
|
|
||||||
display_name = db.Column(db.String(100), nullable=False, default=display_name_default)
|
|
||||||
|
|
||||||
# Links
|
|
||||||
website_url = db.Column(db.String(255), nullable=True, default=None)
|
|
||||||
donate_url = db.Column(db.String(255), nullable=True, default=None)
|
|
||||||
|
|
||||||
# Content
|
|
||||||
notifications = db.relationship("Notification", foreign_keys="Notification.user_id",
|
|
||||||
order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan")
|
|
||||||
caused_notifications = db.relationship("Notification", foreign_keys="Notification.causer_id",
|
|
||||||
back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic")
|
|
||||||
notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user",
|
|
||||||
cascade="all, delete, delete-orphan")
|
|
||||||
|
|
||||||
email_verifications = db.relationship("UserEmailVerification", foreign_keys="UserEmailVerification.user_id",
|
|
||||||
back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic")
|
|
||||||
|
|
||||||
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.causer_id", back_populates="causer",
|
|
||||||
order_by=desc("audit_log_entry_created_at"), lazy="dynamic")
|
|
||||||
|
|
||||||
maintained_packages = db.relationship("Package", lazy="dynamic", secondary="maintainers", order_by=db.asc("package_title"))
|
|
||||||
|
|
||||||
packages = db.relationship("Package", back_populates="author", lazy="dynamic", order_by=db.asc("package_title"))
|
|
||||||
reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan")
|
|
||||||
review_votes = db.relationship("PackageReviewVote", back_populates="user", cascade="all, delete, delete-orphan")
|
|
||||||
tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
|
|
||||||
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
|
||||||
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
|
|
||||||
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
|
||||||
|
|
||||||
def __init__(self, username=None, active=False, email=None, password=None):
|
|
||||||
self.username = username
|
|
||||||
self.display_name = username
|
|
||||||
self.is_active = active
|
|
||||||
self.email = email
|
|
||||||
self.password = password
|
|
||||||
self.rank = UserRank.NOT_JOINED
|
|
||||||
|
|
||||||
def canAccessTodoList(self):
|
|
||||||
return Permission.APPROVE_NEW.check(self) or \
|
|
||||||
Permission.APPROVE_RELEASE.check(self)
|
|
||||||
|
|
||||||
def isClaimed(self):
|
|
||||||
return self.rank.atLeast(UserRank.NEW_MEMBER)
|
|
||||||
|
|
||||||
def getProfilePicURL(self):
|
|
||||||
if self.profile_pic:
|
|
||||||
return self.profile_pic
|
|
||||||
elif self.rank == UserRank.BOT:
|
|
||||||
return "/static/bot_avatar.png"
|
|
||||||
else:
|
|
||||||
return gravatar(self.email or f"{self.username}@content.minetest.net")
|
|
||||||
|
|
||||||
def checkPerm(self, user, perm):
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if type(perm) == str:
|
|
||||||
perm = Permission[perm]
|
|
||||||
elif type(perm) != Permission:
|
|
||||||
raise Exception("Unknown permission given to User.checkPerm()")
|
|
||||||
|
|
||||||
# Members can edit their own packages, and editors can edit any packages
|
|
||||||
if perm == Permission.CHANGE_AUTHOR:
|
|
||||||
return user.rank.atLeast(UserRank.EDITOR)
|
|
||||||
elif perm == Permission.CHANGE_USERNAMES:
|
|
||||||
return user.rank.atLeast(UserRank.MODERATOR)
|
|
||||||
elif perm == Permission.CHANGE_RANK:
|
|
||||||
return user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank)
|
|
||||||
elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
|
|
||||||
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank))
|
|
||||||
elif perm == Permission.CHANGE_DISPLAY_NAME:
|
|
||||||
return user.rank.atLeast(UserRank.MEMBER if user == self else UserRank.MODERATOR)
|
|
||||||
elif perm == Permission.CREATE_TOKEN:
|
|
||||||
if user == self:
|
|
||||||
return user.rank.atLeast(UserRank.MEMBER)
|
|
||||||
else:
|
|
||||||
return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
|
|
||||||
else:
|
|
||||||
raise Exception("Permission {} is not related to users".format(perm.name))
|
|
||||||
|
|
||||||
def canCommentRL(self):
|
|
||||||
from app.models import ThreadReply
|
|
||||||
|
|
||||||
factor = 1
|
|
||||||
if self.rank.atLeast(UserRank.ADMIN):
|
|
||||||
return True
|
|
||||||
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
|
|
||||||
factor *= 2
|
|
||||||
|
|
||||||
one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
|
|
||||||
if ThreadReply.query.filter_by(author=self) \
|
|
||||||
.filter(ThreadReply.created_at > one_min_ago).count() >= 3 * factor:
|
|
||||||
return False
|
|
||||||
|
|
||||||
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
|
|
||||||
if ThreadReply.query.filter_by(author=self) \
|
|
||||||
.filter(ThreadReply.created_at > hour_ago).count() >= 20 * factor:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def canOpenThreadRL(self):
|
|
||||||
from app.models import Thread
|
|
||||||
|
|
||||||
factor = 1
|
|
||||||
if self.rank.atLeast(UserRank.ADMIN):
|
|
||||||
return True
|
|
||||||
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
|
|
||||||
factor *= 5
|
|
||||||
|
|
||||||
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
|
|
||||||
return Thread.query.filter_by(author=self) \
|
|
||||||
.filter(Thread.created_at > hour_ago).count() < 2 * factor
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
if other is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not self.is_authenticated or not other.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
assert self.id > 0
|
|
||||||
return self.id == other.id
|
|
||||||
|
|
||||||
def can_see_edit_profile(self, current_user):
|
|
||||||
return self.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
|
|
||||||
self.checkPerm(current_user, Permission.CHANGE_EMAIL) or \
|
|
||||||
self.checkPerm(current_user, Permission.CHANGE_RANK)
|
|
||||||
|
|
||||||
def can_delete(self):
|
|
||||||
from app.models import ForumTopic
|
|
||||||
return self.packages.count() == 0 and ForumTopic.query.filter_by(author=self).count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
class UserEmailVerification(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
|
||||||
email = db.Column(db.String(100), nullable=False)
|
|
||||||
token = db.Column(db.String(32), nullable=True)
|
|
||||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="email_verifications")
|
|
||||||
is_password_reset = db.Column(db.Boolean, nullable=False, default=False)
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class EmailSubscription(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
email = db.Column(db.String(100), nullable=False, unique=True)
|
|
||||||
blacklisted = db.Column(db.Boolean, nullable=False, default=False)
|
|
||||||
token = db.Column(db.String(32), nullable=True, default=None)
|
|
||||||
|
|
||||||
def __init__(self, email):
|
|
||||||
self.email = email
|
|
||||||
self.blacklisted = False
|
|
||||||
self.token = None
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationType(enum.Enum):
|
|
||||||
# Package / release / etc
|
|
||||||
PACKAGE_EDIT = 1
|
|
||||||
|
|
||||||
# Approval review actions
|
|
||||||
PACKAGE_APPROVAL = 2
|
|
||||||
|
|
||||||
# New thread
|
|
||||||
NEW_THREAD = 3
|
|
||||||
|
|
||||||
# New Review
|
|
||||||
NEW_REVIEW = 4
|
|
||||||
|
|
||||||
# Posted reply to subscribed thread
|
|
||||||
THREAD_REPLY = 5
|
|
||||||
|
|
||||||
# A bot notification
|
|
||||||
BOT = 6
|
|
||||||
|
|
||||||
# Added / removed as maintainer
|
|
||||||
MAINTAINER = 7
|
|
||||||
|
|
||||||
# Editor misc
|
|
||||||
EDITOR_ALERT = 8
|
|
||||||
|
|
||||||
# Editor misc
|
|
||||||
EDITOR_MISC = 9
|
|
||||||
|
|
||||||
# Any other
|
|
||||||
OTHER = 0
|
|
||||||
|
|
||||||
|
|
||||||
def getTitle(self):
|
|
||||||
return self.name.replace("_", " ").title()
|
|
||||||
|
|
||||||
def toName(self):
|
|
||||||
return self.name.lower()
|
|
||||||
|
|
||||||
def get_description(self):
|
|
||||||
if self == NotificationType.PACKAGE_EDIT:
|
|
||||||
return "When another user edits your packages, releases, etc."
|
|
||||||
elif self == NotificationType.PACKAGE_APPROVAL:
|
|
||||||
return "Notifications from editors related to the package approval process."
|
|
||||||
elif self == NotificationType.NEW_THREAD:
|
|
||||||
return "When a thread is created on your package."
|
|
||||||
elif self == NotificationType.NEW_REVIEW:
|
|
||||||
return "When a user posts a review on your package."
|
|
||||||
elif self == NotificationType.THREAD_REPLY:
|
|
||||||
return "When someone replies to a thread you're watching."
|
|
||||||
elif self == NotificationType.BOT:
|
|
||||||
return "From a bot - for example, update notifications."
|
|
||||||
elif self == NotificationType.MAINTAINER:
|
|
||||||
return "When your package's maintainers change."
|
|
||||||
elif self == NotificationType.EDITOR_ALERT:
|
|
||||||
return "For editors: Important alerts."
|
|
||||||
elif self == NotificationType.EDITOR_MISC:
|
|
||||||
return "For editors: Minor notifications, including new threads."
|
|
||||||
elif self == NotificationType.OTHER:
|
|
||||||
return "Minor notifications not important enough for a dedicated category."
|
|
||||||
else:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
return self.value < other.value
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def choices(cls):
|
|
||||||
return [(choice, choice.getTitle()) for choice in cls]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def coerce(cls, item):
|
|
||||||
return item if type(item) == NotificationType else NotificationType[item.upper()]
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
|
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
|
||||||
user = db.relationship("User", foreign_keys=[user_id], back_populates="notifications")
|
|
||||||
|
|
||||||
causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
|
||||||
causer = db.relationship("User", foreign_keys=[causer_id], back_populates="caused_notifications")
|
|
||||||
|
|
||||||
type = db.Column(db.Enum(NotificationType), nullable=False, default=NotificationType.OTHER)
|
|
||||||
|
|
||||||
emailed = db.Column(db.Boolean(), nullable=False, default=False)
|
|
||||||
|
|
||||||
title = db.Column(db.String(100), nullable=False)
|
|
||||||
url = db.Column(db.String(200), nullable=True)
|
|
||||||
|
|
||||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
|
||||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="notifications")
|
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
|
||||||
|
|
||||||
def __init__(self, user, causer, type, title, url, package=None):
|
|
||||||
if len(title) > 100:
|
|
||||||
title = title[:99] + "…"
|
|
||||||
|
|
||||||
self.user = user
|
|
||||||
self.causer = causer
|
|
||||||
self.type = type
|
|
||||||
self.title = title
|
|
||||||
self.url = url
|
|
||||||
self.package = package
|
|
||||||
|
|
||||||
def can_send_email(self):
|
|
||||||
prefs = self.user.notification_preferences
|
|
||||||
return prefs and self.user.email and prefs.get_can_email(self.type)
|
|
||||||
|
|
||||||
def can_send_digest(self):
|
|
||||||
prefs = self.user.notification_preferences
|
|
||||||
return prefs and self.user.email and prefs.get_can_digest(self.type)
|
|
||||||
|
|
||||||
|
|
||||||
class UserNotificationPreferences(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
|
||||||
user = db.relationship("User", back_populates="notification_preferences")
|
|
||||||
|
|
||||||
# 2 = immediate emails
|
|
||||||
# 1 = daily digest emails
|
|
||||||
# 0 = no emails
|
|
||||||
|
|
||||||
pref_package_edit = db.Column(db.Integer, nullable=False)
|
|
||||||
pref_package_approval = db.Column(db.Integer, nullable=False)
|
|
||||||
pref_new_thread = db.Column(db.Integer, nullable=False)
|
|
||||||
pref_new_review = db.Column(db.Integer, nullable=False)
|
|
||||||
pref_thread_reply = db.Column(db.Integer, nullable=False)
|
|
||||||
pref_bot = db.Column(db.Integer, nullable=False)
|
|
||||||
pref_maintainer = db.Column(db.Integer, nullable=False)
|
|
||||||
pref_editor_alert = db.Column(db.Integer, nullable=False)
|
|
||||||
pref_editor_misc = db.Column(db.Integer, nullable=False)
|
|
||||||
pref_other = db.Column(db.Integer, nullable=False)
|
|
||||||
|
|
||||||
def __init__(self, user):
|
|
||||||
self.user = user
|
|
||||||
self.pref_package_edit = 1
|
|
||||||
self.pref_package_approval = 1
|
|
||||||
self.pref_new_thread = 1
|
|
||||||
self.pref_new_review = 1
|
|
||||||
self.pref_thread_reply = 2
|
|
||||||
self.pref_bot = 1
|
|
||||||
self.pref_maintainer = 1
|
|
||||||
self.pref_editor_alert = 1
|
|
||||||
self.pref_editor_misc = 0
|
|
||||||
self.pref_other = 0
|
|
||||||
|
|
||||||
def get_can_email(self, notification_type):
|
|
||||||
return getattr(self, "pref_" + notification_type.toName()) == 2
|
|
||||||
|
|
||||||
def set_can_email(self, notification_type, value):
|
|
||||||
value = 2 if value else 0
|
|
||||||
setattr(self, "pref_" + notification_type.toName(), value)
|
|
||||||
|
|
||||||
def get_can_digest(self, notification_type):
|
|
||||||
return getattr(self, "pref_" + notification_type.toName()) >= 1
|
|
||||||
|
|
||||||
def set_can_digest(self, notification_type, value):
|
|
||||||
if self.get_can_email(notification_type):
|
|
||||||
return
|
|
||||||
|
|
||||||
value = 1 if value else 0
|
|
||||||
setattr(self, "pref_" + notification_type.toName(), value)
|
|
Before Width: | Height: | Size: 980 B |
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 159 B |
After Width: | Height: | Size: 232 B |
After Width: | Height: | Size: 205 B |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 165 B |
After Width: | Height: | Size: 149 B |
After Width: | Height: | Size: 231 B |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 4.0 KiB |