122 Commits

Author SHA1 Message Date
Eva
c705e9b9f1 Merge 9f533882bf into 376f687c38 2025-03-31 23:49:06 +00:00
Eva
9f533882bf client/css: focused active tab styling 2025-04-01 01:48:59 +02:00
Eva
6035278c7e client/css: dark theme comment styling 2025-04-01 01:00:12 +02:00
Eva
acb1eddb53 client/css: text input focus outline 2025-03-30 08:05:47 +02:00
Eva
b3fa26b3fb client/api: set json mime type for metadata 2025-03-29 07:16:08 +01:00
Eva
d3c2da4f91 server/upload: disable default loop flag for videos, detect gif loop 2025-03-29 07:13:56 +01:00
Eva
8acd6ab776 server/search: add safety search synonyms 2025-03-29 07:13:24 +01:00
Eva
2ed19e013a server/posts: more thorough error exception 2025-03-29 07:13:10 +01:00
2b06e1cafa client: fix weird Chrome bug where video controls are invisible under div class="transparent" 2025-03-29 07:12:01 +01:00
7f6211e0cc client: fix displaying 0 in number inputs 2025-03-29 07:11:28 +01:00
Eva
86bbd429f7 client: apply theme as soon as possible
Fixes the default theme flashing on reload
2025-03-29 07:10:55 +01:00
Eva
0b02826e6d client: apply dark theme without a page reload
I'm really confused why the user was expected to reload the page,
there's no technical reason that I can find for it to be this way.
2025-03-29 07:08:33 +01:00
ea215901af client: add more keybinds, prevent typing when focusing input 2025-03-29 07:08:08 +01:00
Eva
9884161297 client/views: prevent errors on slow connections 2025-03-29 07:07:42 +01:00
Eva
b2501b7ee2 client/users: default user icon to site favicon for anonymous uploads 2025-03-29 07:07:09 +01:00
Eva
7f2a8b5b07 client/upload: check "Add relation" box by default 2025-03-29 07:06:49 +01:00
Eva
de9b8fdce3 client/snapshots: display pool name 2025-03-29 07:05:55 +01:00
Eva
ae85605531 client/posts: filter out empty post source lines 2025-03-29 07:05:34 +01:00
Eva
ae72d75631 client/posts: redirect away from /edit when no post edit permissions 2025-03-29 07:03:48 +01:00
Eva
ce613c5ade client/posts: page exit confirmation for bulk edit actions 2025-03-29 07:03:30 +01:00
Eva
e05252f67e client/posts: hide edit icon when unprivileged 2025-03-29 07:03:09 +01:00
Eva
20e03e1397 client/posts: force file download for download button
So that it works regardless of server configuration.
2025-03-29 07:02:47 +01:00
Eva
a9d870eaa8 client/posts: don't crash on edit mode when no permissions 2025-03-29 07:00:49 +01:00
Eva
08bd3bb890 client/posts: better drag alt text on chrome 2025-03-29 07:00:33 +01:00
Eva
b8b51ded15 client/pools: display error message on merge page when no pool selected
Also fixes a js error.
2025-03-29 06:59:37 +01:00
Eva
f590dc6a41 client/pools, client/tags: obvious button to access posts list 2025-03-29 06:59:22 +01:00
Eva
486fc345fe client/pools, client/tags: move description before details 2025-03-29 06:59:22 +01:00
Eva
ae9e596095 client/notes: remove interaction with main overlay area
Right click Copy would not work on Chrome and Firefox.
Right click Save would not work on Firefox.
2025-03-29 06:58:27 +01:00
Eva
5d1dbb291c client/markdown: allow markdown headers
This regex caught more than was necessary, and in the process made it
impossible to use headers. Only replace instances that will actually
trigger tag permalinks.
2025-03-29 06:58:12 +01:00
Eva
a8b6c143eb client/css: use text color for home footer bullets 2025-03-29 06:57:43 +01:00
Eva
4c6d1a216b client/css: space out similar posts on upload page 2025-03-29 06:57:27 +01:00
Eva
9588d11cb0 client/css: gray out disabled radio button circle 2025-03-29 06:52:46 +01:00
Eva
b08c6eca26 client/comments: prevent avatar focus outline from being clipped 2025-03-29 06:50:56 +01:00
376f687c38 chore: questionable is not a recognized rating 2025-02-11 21:50:27 +01:00
4fd848abf2 doc: use docker compose instead of docker-compose
The minimum version requirements are rough guesses, in practice any decently modern docker installation should work.
2025-02-11 21:25:10 +01:00
61b9f81e39 Fixed the google search option in the post details view 2024-11-17 16:48:24 +01:00
b721865931 server/config: generalize container support
Allow running in Kubernetes, podman, and LXC, besides plain docker-compose,
without having to fake out /.dockerenv in non-Docker environments.
2024-11-10 15:44:39 +01:00
Neo
46e3295003 Upload from clipboard (#414)
client/upload: upload from clipboard

Co-authored-by: Eva <evauwu@riseup.net>
2024-09-29 14:54:53 +02:00
031131506e client/css: fix comment word-break
`break-all` makes it hard to read actual comments.
2024-09-29 13:48:06 +02:00
Neo
d102578b54 Merge pull request #647 from po5/null-checks
client: add null checks
2024-04-27 21:23:16 +02:00
Neo
6edb25d87b Merge pull request #641 from po5/mobile
Mobile improvements
2024-04-26 22:56:58 +02:00
Neo
93fc15f2a4 Merge pull request #642 from po5/better-links 2024-04-26 22:37:54 +02:00
Neo
4f9d46e1c2 Merge branch 'master' into better-links 2024-04-26 22:16:37 +02:00
Eva
b72e81850d client: add null checks 2024-03-28 13:31:48 +01:00
Eva
c1c695f082 client/css: stack bulk tagging toggles horizontally on mobile 2024-03-21 22:26:49 +01:00
Eva
4b6b231fc8 client/posts: reorder elements in mobile layout
Navigation is always right below the image, and comments are always
at the very bottom, to minimize scrolling for common actions.
2024-03-21 22:26:28 +01:00
Eva
6b0c3cfc7f client/html: allow mobile browsers to zoom in 2024-03-21 22:23:45 +01:00
Eva
4ec8cb3ba2 client/css: constrain thumbnails to parent to prevent overextended links 2024-03-21 22:19:46 +01:00
Eva
8d971234a2 client/views: better pool name fallback 2024-03-21 22:16:05 +01:00
Eva
a16bb198ab client/views: more thorough link fallbacks
Prevents a bunch of errors that can happen when a resource is deleted.
2024-03-21 21:53:11 +01:00
Eva
3f182a66ad client/posts: fix overextended tag link 2024-03-21 21:52:52 +01:00
Eva
b52363e82d client/posts: fix overextended download link 2024-03-21 21:52:49 +01:00
Eva
3bf45e4c0a client/users: fix overextended avatar links 2024-03-21 21:52:39 +01:00
5596f53744 posts page ugly horizontal bar fix
fixes ugly horizontal scrollbar appearing when a post with extremely wide image is present in the posts list
2024-02-29 20:56:27 +01:00
da425afc49 Pin pillow-avif-plugin to compatible version range 2024-02-21 17:47:27 +01:00
d7394d672f Fix Pool Search 2024-02-21 01:27:00 +01:00
190d795426 doc: fix small error in pool API docs 2023-12-05 21:31:23 +01:00
7c92ceaf6a fix overflow on comments, prevents ugly unnecesary horizontal scroll 2023-11-05 12:27:03 +01:00
Neo
9e00f37464 Merge pull request #597 from zakame/use-yt-dlp
server/net: use yt-dlp instead of youtube-dl
2023-11-05 12:22:03 +01:00
59c497e168 doc: update for yt-dlp 2023-08-17 20:58:09 +08:00
c292b96f06 server/net: use yt-dlp instead of youtube-dl
youtube-dl no longer even gets URLs properly, so switch to yt-dlp as a
drop-in replacement for it.
2023-08-17 20:41:50 +08:00
7a82e9d581 tests/server: post category filter 2023-07-05 12:22:11 +00:00
4806bbe0ed server: post category filter 2023-07-05 12:22:11 +00:00
c2fdc2d070 docs (tag categories): order is required when creating tag category 2023-06-26 20:49:48 +02:00
ffdf115714 docs (api): change micro post attribute name to id 2023-06-26 20:49:48 +02:00
782f069031 client/upload: fix thumbnail width in post uploads
Fixes regression caused by 648121d7
2023-04-17 19:50:40 -04:00
81f7ae8034 client: fix post flow view on webkit browsers
Merge branch 'SediSocks-master'
2023-04-17 12:30:21 -04:00
648121d7c3 client+server: add quicktime video support
Merge branch 'skybldev-upstream'
2023-04-17 12:21:26 -04:00
42524503b9 client/tests: add unit tests for quicktime videos 2023-04-17 12:01:20 -04:00
8a03015349 client+server: added quicktime upload support 2023-04-17 11:36:44 -04:00
2165b59158 client: merge dependabot version bumps
Merge remote-tracking branches:
- 'project/dependabot/npm_and_yarn/client/cookiejar-2.1.4'
- 'project/dependabot/npm_and_yarn/client/decode-uri-component-0.2.2'
- 'project/dependabot/npm_and_yarn/client/jpeg-js-0.4.4'
- 'project/dependabot/npm_and_yarn/client/minimist-1.2.6'
- project/dependabot/npm_and_yarn/client/qs-6.11.0'
- 'project/dependabot/npm_and_yarn/client/shell-quote-1.7.3'
- 'project/dependabot/npm_and_yarn/client/terser-4.8.1'
2023-04-17 11:30:47 -04:00
244a0f0b6c server/test: skip network tests by default 2023-04-17 10:31:35 -04:00
da3b4790ad server+client: bump versions in pre-commit 2023-04-17 10:31:35 -04:00
196f92593c fix flow view on webkit browsers 2023-03-13 19:53:02 +00:00
d7d2a151a8 client: workaround for #545, but not a fix 2023-01-24 22:19:24 +01:00
75635bbc43 build(deps): bump cookiejar from 2.1.2 to 2.1.4 in /client
Bumps [cookiejar](https://github.com/bmeck/node-cookiejar) from 2.1.2 to 2.1.4.
- [Release notes](https://github.com/bmeck/node-cookiejar/releases)
- [Commits](https://github.com/bmeck/node-cookiejar/commits)

---
updated-dependencies:
- dependency-name: cookiejar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-23 20:36:57 +00:00
Neo
e3062b1c77 client: add bulk delete feature (#459)
This introduces a new privilege 'posts:bulk-edit:delete' which by default is given to power users.
2023-01-19 18:44:31 +01:00
e950fe7ea5 build(deps): bump qs from 6.5.2 to 6.11.0 in /client
Bumps [qs](https://github.com/ljharb/qs) from 6.5.2 to 6.11.0.
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.5.2...v6.11.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-07 17:36:52 +00:00
86f50ec742 build(deps): bump decode-uri-component from 0.2.0 to 0.2.2 in /client
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-07 15:29:20 +00:00
8088ff3bbe support ftypiso6 file signature 2022-09-13 19:18:22 +02:00
da71c672dd build(deps-dev): bump terser from 3.7.7 to 4.8.1 in /client
Bumps [terser](https://github.com/terser/terser) from 3.7.7 to 4.8.1.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-20 01:23:35 +00:00
42bb364dd0 build(deps): bump shell-quote from 1.6.1 to 1.7.3 in /client
Bumps [shell-quote](https://github.com/substack/node-shell-quote) from 1.6.1 to 1.7.3.
- [Release notes](https://github.com/substack/node-shell-quote/releases)
- [Changelog](https://github.com/substack/node-shell-quote/blob/master/CHANGELOG.md)
- [Commits](https://github.com/substack/node-shell-quote/compare/1.6.1...1.7.3)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-21 21:38:21 +00:00
5b43c5bebd build(deps): bump jpeg-js from 0.4.0 to 0.4.4 in /client
Bumps [jpeg-js](https://github.com/eugeneware/jpeg-js) from 0.4.0 to 0.4.4.
- [Release notes](https://github.com/eugeneware/jpeg-js/releases)
- [Commits](https://github.com/eugeneware/jpeg-js/compare/v0.4.0...v0.4.4)

---
updated-dependencies:
- dependency-name: jpeg-js
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-17 01:45:34 +00:00
6c3b50d287 doc: add GET /post/<id>/around to API.md 2022-06-10 01:49:07 +02:00
6075ae9326 all: add .gitattributes
This forces shell scripts to always have LF line endings. By default Windows uses CRLF which breaks the docker build, because docker-start.sh doesn't have the correct line endings. Adding this file should fix that.
2022-05-02 13:04:07 +02:00
70f2164dc6 build(deps): bump minimist from 1.2.5 to 1.2.6 in /client
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-31 18:56:34 -04:00
1b9ce79f4e client+server: only trigger autobuild on master branch pushes 2022-03-31 18:54:08 -04:00
7e5d48b6e8 build(deps): bump minimist from 1.2.5 to 1.2.6 in /client
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-31 22:45:47 +00:00
e746f09911 server: fix build error due to broken pip requirements
Pinned pyheif to v0.6.1
2022-03-31 18:43:37 -04:00
6088e89ea1 server/szuru-admin: Add thumbnail regeneration script
Closes #467
2022-03-30 23:04:16 -04:00
79d0efc25b doc: added BuildKit flags fix to INSTALL.md
Added this because recently, there have been more problems with `docker-compose build` where it errors:

    ERROR: Service 'server' failed to build: failed to parse platform : "" is an invalid component of "": platform specifier component must match "^[A-Za-z0-9_-]+$": invalid argument

Recent Docker versions have switched to using `buildx` (BuildKit) to build containers, but that needs to be enabled, either in `daemon.json` or through an environment variable. But since we are using Docker Compose, it doesn't pass it to Docker; so the environment variable needs to be set. At least that's what I've heard and figured out sweat_smile My explanation might be very wrong - but it works :)
2022-03-30 22:47:03 -04:00
929071ea1a doc: fix external link in README.md 2022-03-30 22:44:32 -04:00
514b846781 client/js/markdown: fix processing of inline markdown 2022-02-16 09:09:21 -05:00
b2582b7b0f client: update dependencies 2022-02-14 18:31:15 -05:00
82541536af Make waitress thread count configurable.
This should fix most scaling problems without needing to start
more server instances. By default, waitress maintains at most
4 threads. This works fine if the database is small (sub 100k posts)
but causes a large Task queue depth to occur if the database is larger.

Letting users increase the amount of threads means that one server instance
is able to handle more requests without locking up the rest of the site.

This adds a new environment variable to .env, THREADS, which can be used to
configure the amount of threads to start and is by default set to 4
(the default amount used by waitress).
2022-02-14 17:33:23 -05:00
8ad9457b24 build(deps): bump path-parse from 1.0.6 to 1.0.7 in /client
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-08 15:00:00 +00:00
6de0a74257 server/config: fix deprecated database string format 2022-02-08 09:58:56 -05:00
a22485afda server/func/images: upgrade to heif-image-plugin 2022-02-08 09:58:33 -05:00
e2419a30ba build(deps): bump cached-path-relative from 1.0.2 to 1.1.0 in /client
Bumps [cached-path-relative](https://github.com/ashaffer/cached-path-relative) from 1.0.2 to 1.1.0.
- [Release notes](https://github.com/ashaffer/cached-path-relative/releases)
- [Commits](https://github.com/ashaffer/cached-path-relative/commits)

---
updated-dependencies:
- dependency-name: cached-path-relative
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-27 14:29:29 +00:00
d5a6609f75 client: remove URL rewriting from the markdown handler 2022-01-26 20:29:31 +00:00
106dcc4135 server/func/images: Do not pass file content to ffmpeg stdin 2022-01-16 11:07:46 -05:00
a14ead1842 build(deps): bump marked from 0.7.0 to 4.0.10 in /client
Bumps [marked](https://github.com/markedjs/marked) from 0.7.0 to 4.0.10.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Changelog](https://github.com/markedjs/marked/blob/master/.releaserc.json)
- [Commits](https://github.com/markedjs/marked/compare/v0.7.0...v4.0.10)

---
updated-dependencies:
- dependency-name: marked
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-15 01:05:54 +00:00
780b7dc6fd client/upload: restore option to pause upload chain on error 2021-11-29 20:06:20 -05:00
9f95e9eb90 client: linting 2021-11-29 18:44:20 -05:00
9b3123a815 server: fix python docstring formatting 2021-11-29 18:39:34 -05:00
f3aa0eb801 dev/pre-commit: update versions for pre-commit hooks 2021-11-29 18:34:17 -05:00
98c0941c97 client/docker: Do not pin LTS version of Node
See: https://github.com/npm/cli/wiki/Support-Policy#long-term-support-lts
2021-11-29 18:09:56 -05:00
a5fbaae4b3 updated build files
-  is no longer valid as per https://github.com/npm/cli/wiki/Support-Policy#long-term-support-lts
- updated pre-commit config to use latest repos
2021-11-28 10:07:04 -05:00
d699979d35 client+server: cleanup GitHub actions workflow names
Also run unit test action on push
2021-09-23 12:49:32 -04:00
d083084407 server/tests: use transactional db for faster unit tests
* `test_modify_saves_non_empty_diffs` needs non-transactional
  db, so moved to seperate file
* Replaced incompatable usage of `db.session.rollback()`
  with parametrerized function calls
* xfail conditionals for search removed, as we can no longer
  get current driver with binds
* Also remove usage of deprecated `pytest.yield_fixture`
2021-09-23 12:24:56 -04:00
ad9d3599bc server/net: return more useful error messages 2021-09-22 22:08:07 -04:00
c3b81371d8 client+server/docker: fix ARM build platform issue 2021-09-19 12:03:32 -04:00
c64983002e client+server/docker: build ARM images for Docker Hub 2021-09-19 11:39:40 -04:00
4f57f49ebe client+server: migrate to GitHub actions 2021-09-19 11:01:47 -04:00
f58079e12e client/upload: force enable 'upload anonymously' for anon users
Fixes #425
2021-09-13 14:24:07 -04:00
be0c867d25 client/upload: add QoL features for bulk uploads
* Continue uploading remaining posts in an upload list even
when one fails

* Allow option to continue uploading even when similar posts are found

Closes #400
2021-09-13 13:28:34 -04:00
f5338ca508 Fix style 2021-09-13 13:26:57 -04:00
e4a253fd25 client+server: fixed style errors 2021-09-13 13:25:37 -04:00
414106a477 client/css: dark mode contrast fixes (#388)
* client/css: fix dark mode pagination header bg

* client/css/post-main-view: dark uses box-shadow

* client/css: animate compact-tags updates

* client: tag input animations fixed

* client/css: darken darktheme success bg

* client/css: dark tag background colors

* client/css/tag-input-control: dark suggest header

* client/css: darktheme mobile site-name in nav
2021-07-05 13:24:04 +02:00
fa4997fbb9 server: fix issue where no video files could be uploaded 2021-06-07 00:37:30 +02:00
516b3a51a7 Option to always upload similar posts instead of confirming every time 2021-05-07 23:24:38 -07:00
f4ca435657 If one post fails to upload, don't prevent the rest from uploading 2021-05-07 23:02:59 -07:00
104 changed files with 1336 additions and 660 deletions

5
.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
# Auto detect text files and perform LF normalization
* text=auto
# Shell scripts require LF
*.sh text eol=lf

108
.github/workflows/build-containers.yml vendored Normal file
View File

@ -0,0 +1,108 @@
name: Build Docker containers
on:
push:
branches:
- master
jobs:
build-client:
name: Build and push client/ Docker container
runs-on: ubuntu-latest
steps:
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Determine metadata
run: |
CLOSEST_VER="$(git describe --tags --abbrev=0 $GITHUB_SHA)"
CLOSEST_MAJOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f1)"
CLOSEST_MINOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f2)"
SHORT_COMMIT=$(echo $GITHUB_SHA | cut -c1-8)
BUILD_INFO="v${CLOSEST_VER}-${SHORT_COMMIT}"
BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
echo "major_tag=${CLOSEST_MAJOR_VER}" >> $GITHUB_ENV
echo "minor_tag=${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}" >> $GITHUB_ENV
echo "build_info=${BUILD_INFO}" >> $GITHUB_ENV
echo "build_date=${BUILD_DATE}" >> $GITHUB_ENV
echo "Build Info: ${BUILD_INFO}"
echo "Build Date: ${BUILD_DATE}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build container
run: >
docker buildx build --push
--platform linux/amd64,linux/arm/v7,linux/arm64/v8
--build-arg BUILD_INFO=${{ env.build_info }}
--build-arg BUILD_DATE=${{ env.build_date }}
--build-arg SOURCE_COMMIT=$GITHUB_SHA
--build-arg DOCKER_REPO=szurubooru/client
-t "szurubooru/client:latest"
-t "szurubooru/client:${{ env.major_tag }}"
-t "szurubooru/client:${{ env.minor_tag }}"
./client
build-server:
name: Build and push server/ Docker container
runs-on: ubuntu-latest
steps:
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Determine metadata
run: |
CLOSEST_VER="$(git describe --tags --abbrev=0 $GITHUB_SHA)"
CLOSEST_MAJOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f1)"
CLOSEST_MINOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f2)"
SHORT_COMMIT=$(echo $GITHUB_SHA | cut -c1-8)
BUILD_INFO="v${CLOSEST_VER}-${SHORT_COMMIT}"
BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
echo "major_tag=${CLOSEST_MAJOR_VER}" >> $GITHUB_ENV
echo "minor_tag=${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}" >> $GITHUB_ENV
echo "build_info=${BUILD_INFO}" >> $GITHUB_ENV
echo "build_date=${BUILD_DATE}" >> $GITHUB_ENV
echo "Build Info: ${BUILD_INFO}"
echo "Build Date: ${BUILD_DATE}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build container
run: >
docker buildx build --push
--platform linux/amd64,linux/arm/v7,linux/arm64/v8
--build-arg BUILD_DATE=${{ env.build_date }}
--build-arg SOURCE_COMMIT=$GITHUB_SHA
--build-arg DOCKER_REPO=szurubooru/server
-t "szurubooru/server:latest"
-t "szurubooru/server:${{ env.major_tag }}"
-t "szurubooru/server:${{ env.minor_tag }}"
./server

28
.github/workflows/run-unit-tests.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Run unit tests
on: [push, pull_request]
jobs:
test-server:
name: Run pytest for server/
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build test container
run: >
docker buildx build --load
--platform linux/amd64 --target testing
-t test_container
./server
- name: Run unit tests
run: >
docker run --rm -t test_container
--color=no
--cov-report=term-missing:skip-covered
--cov=szurubooru
szurubooru/

View File

@ -1,28 +1,29 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.1.9
rev: v1.4.2
hooks:
- id: remove-tabs
- repo: https://github.com/psf/black
rev: 20.8b1
rev: '23.1.0'
hooks:
- id: black
files: 'server/'
types: [python]
language_version: python3.8
language_version: python3.9
- repo: https://github.com/timothycrosley/isort
rev: '5.4.2'
- repo: https://github.com/PyCQA/isort
rev: '5.12.0'
hooks:
- id: isort
files: 'server/'
@ -31,8 +32,8 @@ repos:
additional_dependencies:
- toml
- repo: https://github.com/prettier/prettier
rev: '2.1.1'
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
files: client/js/
@ -40,7 +41,7 @@ repos:
args: ['--config', 'client/.prettierrc.yml']
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v7.8.0
rev: v8.33.0
hooks:
- id: eslint
files: client/js/
@ -48,8 +49,8 @@ repos:
additional_dependencies:
- eslint-config-prettier
- repo: https://gitlab.com/pycqa/flake8
rev: '3.8.3'
- repo: https://github.com/PyCQA/flake8
rev: '6.0.0'
hooks:
- id: flake8
files: server/szurubooru/
@ -57,44 +58,5 @@ repos:
- flake8-print
args: ['--config=server/.flake8']
- repo: local
hooks:
- id: docker-build-client
name: Docker - build client
entry: bash -c 'docker build client/'
language: system
types: [file]
files: client/
pass_filenames: false
- id: docker-build-server
name: Docker - build server
entry: bash -c 'docker build server/'
language: system
types: [file]
files: server/
pass_filenames: false
- id: pytest
name: pytest
entry: bash -c 'docker run --rm -t $(docker build --target testing -q server/) szurubooru/'
language: system
types: [python]
files: server/szurubooru/
exclude: server/szurubooru/migrations/
pass_filenames: false
stages: [push]
- id: pytest-cov
name: pytest
entry: bash -c 'docker run --rm -t $(docker build --target testing -q server/) --cov-report=term-missing:skip-covered --cov=szurubooru szurubooru/'
language: system
types: [python]
files: server/szurubooru/
exclude: server/szurubooru/migrations/
pass_filenames: false
verbose: true
stages: [manual]
fail_fast: true
exclude: LICENSE.md

View File

@ -3,12 +3,12 @@
Szurubooru is an image board engine inspired by services such as Danbooru,
Gelbooru and Moebooru dedicated for small and medium communities. Its name [has
its roots in Polish language and has onomatopeic meaning of scraping or
scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
scrubbing](https://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
## Features
- Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations
- Ability to retrieve web video content using [youtube-dl](https://github.com/ytdl-org/youtube-dl)
- Ability to retrieve web video content using [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- Post comments
- Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](doc/API.md))

View File

@ -1,8 +1,7 @@
FROM node:lts as builder
FROM --platform=$BUILDPLATFORM node:lts as builder
WORKDIR /opt/app
COPY package.json package-lock.json ./
RUN npm install -g npm@lts
RUN npm install
COPY . ./
@ -12,7 +11,7 @@ ARG CLIENT_BUILD_ARGS=""
RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS}
FROM scratch as approot
FROM --platform=$BUILDPLATFORM scratch as approot
COPY docker-start.sh /

View File

@ -4,6 +4,9 @@ $comment-header-background-color-darktheme = $top-navigation-color-darktheme
$comment-border-color = #DDD
.comments-container
margin-top: 2em
.comment-container
padding: 0 0 0 60px
@ -16,7 +19,13 @@ $comment-border-color = #DDD
width: 40px
height: 40px
a
outline: none
display: inline-block
position: relative
top: -2px
border: 2px solid transparent
&:focus
border: 2px solid $main-color
nav:not(.active), .tab:not(.active)
display: none
@ -114,19 +123,36 @@ $comment-border-color = #DDD
.messages
margin: 1em 0
.darktheme .comment-container .comment header
background: $comment-header-background-color-darktheme
nav.edit
ul
li
&.active
background: $window-color-darktheme
border-bottom: 1px solid $window-color-darktheme
.edit, .delete, .score-container a, .nickname a
&:not(.inactive)
color: mix($main-color, $inactive-link-color-darktheme)
.darktheme .comment-container
.comment
border: 1px solid $comment-header-background-color-darktheme
header
background: $comment-header-background-color-darktheme
border-bottom: 1px solid $comment-header-background-color-darktheme
nav.edit
ul
li
&.active
background: $window-color-darktheme
border: 1px solid $window-color-darktheme
.edit, .delete, .score-container a, .nickname a
&:not(.inactive)
color: mix($main-color, $inactive-link-color-darktheme)
&:before
border-right: 0
&:after
border-right: 0.75em solid $comment-header-background-color-darktheme
.comment-content
p
word-wrap: normal
word-break: break-word
ul, ol
list-style-position: inside
margin: 1em 0

View File

@ -125,6 +125,9 @@ input[type=radio]:checked + .radio:after,
input[type=checkbox]:checked + .checkbox:after
opacity: 1
input[type=radio]:disabled + .radio:after
background: $input-disabled-text-color
input[type=radio]:disabled + .radio:before,
input[type=checkbox]:disabled + .checkbox:before,
input[type=radio]:disabled + .radio:after,
@ -203,6 +206,7 @@ input[type=number]
background: $input-enabled-background-color
color: $input-enabled-text-color
box-shadow: none /* :-moz-submit-invalid on FF */
outline: none
transition: border-color 0.1s linear, background-color 0.1s linear
&:disabled
@ -211,7 +215,7 @@ input[type=number]
color: $input-disabled-text-color
&:focus
border-color: $main-color
border-color: $main-color !important
&[readonly]
border: 2px solid $input-disabled-border-color

View File

@ -106,7 +106,12 @@ form .fa-question-circle-o
background-color: $scrollbar-bg-color
&::-webkit-scrollbar-thumb
background-color: $scrollbar-thumb-color
>.content-wrapper:not(.transparent)
li[data-name=view]
background: $button-enabled-background-color
margin-right: 1em
a
color: $button-enabled-text-color
>.content-wrapper:not(.transparent-container)
background: $top-navigation-color
padding: 1.8em
@media (max-width: 1000px)
@ -117,7 +122,7 @@ form .fa-question-circle-o
margin-bottom: 0
.darktheme #content-holder
>.content-wrapper:not(.transparent)
>.content-wrapper:not(.transparent-container)
background: $top-navigation-color-darktheme
hr
@ -161,6 +166,8 @@ nav
li.active a
background: $active-tab-background-color
color: $active-tab-text-color
li.active:has(:focus)
background: $active-tab-background-color
:focus
background: $focused-tab-background-color
outline: 0
@ -236,10 +243,15 @@ nav
li.active a
background: $active-tab-background-color-darktheme
color: $active-tab-text-color-darktheme
li.active:has(:focus)
background: $active-tab-background-color-darktheme
:focus
background: $focused-tab-background-color-darktheme
&#top-navigation
background: $top-navigation-color-darktheme
ul
#mobile-navigation-toggle
color: $text-color-darktheme
a .access-key
text-decoration: underline
@ -275,7 +287,7 @@ a .access-key
background: darken($message-error-background-color, 60%)
&.success
border: 1px solid darken($message-success-border-color, 30%)
background: darken($message-success-background-color, 60%)
background: darken($message-success-background-color, 80%)
.thumbnail
/*background-image: attr(data-src url)*/ /* not available yet */

View File

@ -61,7 +61,10 @@
word-spacing: 1.1em
background-repeat: no-repeat
background-position: 50% 50%
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12'><circle cx='6' cy='6' r='2' fill='%23000000'/></svg>")
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12'><circle cx='6' cy='6' r='2' fill='%23111111'/></svg>")
.thumbnail
margin-right: 0.4em
.darktheme #home footer ul .sep
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12'><circle cx='6' cy='6' r='2' fill='%23e6e6e6'/></svg>")

View File

@ -22,7 +22,7 @@
z-index: 1
span
position: relative
background: white
background: $window-color
padding: 0 1em
z-index: 2
@ -31,3 +31,5 @@
.page-header
&:before
background: $top-navigation-color-darktheme
span
background: $window-color-darktheme

View File

@ -114,6 +114,29 @@
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
.delete-flipper
display: inline-block
padding: 0.5em
box-sizing: border-box
border: 0
&:after
display: inline-block
width: 1em
height: 1em
text-align: center
line-height: 1em
font-size: 2.2em
&.delete
background: rgba(255, 0, 0, 0.7)
&:after
color: white
font-family: FontAwesome;
content: "\f1f8"; // fa-trash
&:not(.delete)
background: rgba(200, 200, 200, 0.7)
&:after
color: white
content: '-'
.thumbnail
width: 100%
@ -164,6 +187,9 @@
vertical-align: top
@media (max-width: 1000px)
display: block
&.bulk-edit-tags:not(.opened), &.bulk-edit-safety:not(.opened)
float: left
margin-right: 1em
input
margin-bottom: 0.25em
margin-right: 0.25em
@ -215,7 +241,19 @@
.append
@media (max-width: 1000px)
margin-left: 0
.bulk-edit-delete
&.opened
.start
@media (max-width: 1000px)
margin-left: 0
&:not(.opened)
.start
display: none
.append.open
@media (max-width: 1000px)
margin-left: 0
.start
margin-left: 1em
.safety
margin-right: 0.25em
&.safety-safe

View File

@ -15,39 +15,53 @@
border: 0
outline: 0
nav.buttons
margin-top: 0
display: flex
flex-wrap: wrap
article
flex: 1 0 33%
a
display: inline-block
width: 100%
padding: 0.3em 0
text-align: center
vertical-align: middle
transition: background 0.2s linear
&:not(.inactive):hover
background: lighten($main-color, 90%)
i
font-size: 140%
>.sidebar>nav.buttons, >.content nav.buttons
margin-top: 0
display: flex
flex-wrap: wrap
article
flex: 1 0 33%
a
display: inline-block
width: 100%
padding: 0.3em 0
text-align: center
@media (max-width: 800px)
margin-top: 2em
vertical-align: middle
transition: background 0.2s linear, box-shadow 0.2s linear
&:not(.inactive):hover
background: lighten($main-color, 90%)
i
font-size: 140%
text-align: center
@media (max-width: 800px)
margin-top: 0.6em
margin-bottom: 0.6em
>.content
width: 100%
.post-container
margin-bottom: 2em
margin-bottom: 0.6em
.post-content
margin: 0
.after-mobile-controls
width: 100%
.darktheme .post-view
>.sidebar, >.content
nav.buttons
article
a:not(.inactive):hover
background: unset
box-shadow: inset 0 0 0 0.3em $main-color
@media (max-width: 800px)
.post-view
flex-wrap: wrap
>.after-mobile-controls
order: 3
>.sidebar
order: 2
min-width: 100%
@ -105,7 +119,6 @@
h1
margin-bottom: 0.5em
.thumbnail
background-position: 50% 30%
width: 4em
height: 3em
li

View File

@ -18,6 +18,8 @@
right: 0
top: 0
bottom: 0
&[data-state=read-only]
pointer-events: none
.notes-overlay
g

View File

@ -13,8 +13,12 @@ $cancel-button-color = tomato
&.inactive input[type=submit],
&.inactive .skip-duplicates
&.inactive .always-upload-similar
&.inactive .pause-remain-on-error
&.uploading input[type=submit],
&.uploading .skip-duplicates,
&.uploading .always-upload-similar
&.uploading .pause-remain-on-error
&:not(.uploading) .cancel
display: none
@ -39,6 +43,12 @@ $cancel-button-color = tomato
.skip-duplicates
margin-left: 1em
.always-upload-similar
margin-left: 1em
.pause-remain-on-error
margin-left: 1em
form>.messages
margin-top: 1em
@ -52,6 +62,14 @@ $cancel-button-color = tomato
margin: 0 0 1.2em 0
padding-left: 13em
img
width: 100%
height: 100%
video
width: 100%
height: 100%
&>.thumbnail-wrapper
float: left
width: 12em
@ -127,6 +145,7 @@ $cancel-button-color = tomato
clear: both
margin: 1em 0 0 0
padding-left: 7em
padding-bottom: 1em;
font-size: 90%
.thumbnail-wrapper

View File

@ -86,6 +86,12 @@ div.tag-input
font-size: 90%
unselectable()
@keyframes tag-added-to-post
from
max-height: 0
to
max-height: 5em
ul.compact-tags
width: 100%
margin: 0.5em 0 0 0
@ -103,18 +109,30 @@ ul.compact-tags
a:focus
outline: 0
box-shadow: inset 0 0 0 2px $main-color
// these 3 added when tag is added to ul
&.added, &.new, &.implication
animation: tag-added-to-post 1s ease forwards
&.implication
background: $implied-tag-background-color
color: $implied-tag-text-color
background-color: $implied-tag-background-color
&.new
background: $new-tag-background-color
color: $new-tag-text-color
background-color: $new-tag-background-color
&.duplicate
background: $duplicate-tag-background-color
color: $duplicate-tag-text-color
background-color: $duplicate-tag-background-color
i
padding-right: 0.4em
.darktheme ul.compact-tags
li
&.new
background-color: darken($new-tag-background-color, 80%)
&.implication
background-color: darken($implied-tag-background-color, 85%)
&.duplicate
background-color: darken($duplicate-tag-background-color, 80%)
div.tag-input, ul.compact-tags
.tag-usages, .tag-weight, .remove-tag
color: $inactive-link-color
@ -134,6 +152,8 @@ div.tag-input, ul.compact-tags
background: $window-color-darktheme
ul li:last-child
border-bottom: 0.5em solid alpha($window-color-darktheme, 0)
p
background: darken($tag-suggestions-header-color, 80%)
.append
color: $inactive-link-color-darktheme
div.tag-input, ul.compact-tags

View File

@ -21,10 +21,11 @@
.details
font-size: 90%
line-height: 130%
.image
margin: 0.25em 0.6em 0.25em 0
.thumbnail
width: 3em
height: 3em
margin: 0.25em 0.6em 0 0
.darktheme .user-list
ul li

View File

@ -1,16 +0,0 @@
#!/bin/sh
CLOSEST_VER=$(git describe --tags --abbrev=0 ${SOURCE_COMMIT})
if git describe --exact-match --abbrev=0 ${SOURCE_COMMIT} 2> /dev/null; then
BUILD_INFO="v${CLOSEST_VER}"
else
BUILD_INFO="v${CLOSEST_VER}-edge-$(git rev-parse --short ${SOURCE_COMMIT})"
fi
echo "Using BUILD_INFO=${BUILD_INFO}"
docker build \
--build-arg BUILD_INFO=${BUILD_INFO} \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg SOURCE_COMMIT \
--build-arg DOCKER_REPO \
-f $DOCKERFILE_PATH -t $IMAGE_NAME .

View File

@ -1,19 +0,0 @@
#!/bin/sh
add_tag() {
echo "Also tagging image as ${DOCKER_REPO}:${1}"
docker tag $IMAGE_NAME $DOCKER_REPO:$1
docker push $DOCKER_REPO:$1
}
CLOSEST_VER=$(git describe --tags --abbrev=0)
CLOSEST_MAJOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f1)
CLOSEST_MINOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f2)
add_tag "${CLOSEST_MAJOR_VER}-edge"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}-edge"
if git describe --exact-match --abbrev=0 2> /dev/null; then
add_tag "${CLOSEST_MAJOR_VER}"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}"
fi

View File

@ -34,6 +34,16 @@ shortcuts:</p>
<td>Focus first post in post list</td>
</tr>
<tr>
<td><kbd>T</kbd></td>
<td>(In edit mode) Focus tag input</td>
</tr>
<tr>
<td><kbd>Command/Ctrl+S</kbd></td>
<td>(In edit mode) Save post</td>
</tr>
<tr>
<td><kbd>Delete</kbd></td>
<td>Delete post (while in edit mode)</td>

View File

@ -1,4 +1,4 @@
<div class='content-wrapper transparent' id='home'>
<div class='content-wrapper transparent-container' id='home'>
<div class='messages'></div>
<header>
<h1><%- ctx.name %></h1>

View File

@ -2,7 +2,7 @@
<html>
<head>
<meta charset='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'/>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<meta name='theme-color' content='#24aadd'/>
<meta name='apple-mobile-web-app-capable' content='yes'/>
<meta name='apple-mobile-web-app-status-bar-style' content='black'/>

View File

@ -2,6 +2,7 @@
<h1><%- ctx.getPrettyName(ctx.pool.names[0]) %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li data-name='view'><a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'>View</a></li><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id) %>'>Summary</a></li><!--
--><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'edit') %>'>Edit</a></li><!--

View File

@ -1,4 +1,10 @@
<div class='content-wrapper pool-summary'>
<section class='description'>
<%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
<hr/>
</section>
<section class='details'>
<section>
Category:
@ -14,10 +20,4 @@
--></ul>
</section>
</section>
<section class='description'>
<hr/>
<%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
</section>
</div>

View File

@ -1,7 +1,7 @@
<div class='post-content post-type-<%- ctx.post.type %>'>
<% if (['image', 'animation'].includes(ctx.post.type)) { %>
<img class='resize-listener' alt='' src='<%- ctx.post.contentUrl %>'/>
<img class='resize-listener' alt='' src='<%- ctx.post.contentUrl %>' draggable='false'/>
<% } else if (ctx.post.type === 'flash') { %>

View File

@ -1,4 +1,4 @@
<div class='content-wrapper transparent post-view'>
<div class='content-wrapper transparent-container post-view'>
<aside class='sidebar'>
<nav class='buttons'>
<article class='previous-post'>
@ -29,6 +29,7 @@
<span class='vim-nav-hint'>Next post &gt;</span>
</a>
</article>
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
<article class='edit-post'>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
@ -36,16 +37,13 @@
<span class='vim-nav-hint'>Back to view mode</span>
</a>
<% } else { %>
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
<% } else { %>
<a class='inactive'>
<% } %>
<i class='fa fa-pencil'></i>
<span class='vim-nav-hint'>Edit post</span>
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
<i class='fa fa-pencil'></i>
<span class='vim-nav-hint'>Edit post</span>
</a>
<% } %>
</article>
<% } %>
</nav>
<div class='sidebar-container'></div>
@ -54,13 +52,15 @@
<div class='content'>
<div class='post-container'></div>
<% if (ctx.canListComments) { %>
<div class='comments-container'></div>
<% } %>
<div class='after-mobile-controls'>
<% if (ctx.canCreateComments) { %>
<h2>Add comment</h2>
<div class='comment-form-container'></div>
<% } %>
<% if (ctx.canCreateComments) { %>
<h2>Add comment</h2>
<div class='comment-form-container'></div>
<% } %>
<% if (ctx.canListComments) { %>
<div class='comments-container'></div>
<% } %>
</div>
</div>
</div>

View File

@ -42,6 +42,7 @@
'image/heic': 'HEIC',
'video/webm': 'WEBM',
'video/mp4': 'MPEG-4',
'video/quicktime': 'MOV',
'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] +
' (' +

View File

@ -1,7 +1,7 @@
<div class='readonly-sidebar'>
<article class='details'>
<section class='download'>
<a rel='external' href='<%- ctx.post.contentUrl %>'>
<a href='<%- ctx.post.contentUrl %>' download>
<i class='fa fa-download'></i><!--
--><%= ctx.makeFileSize(ctx.post.fileSize) %> <!--
--><%- {
@ -15,9 +15,10 @@
'image/heic': 'HEIC',
'video/webm': 'WEBM',
'video/mp4': 'MPEG-4',
'video/quicktime': 'MOV',
'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] %>
</a>
}[ctx.post.mimeType] %><!--
--></a>
(<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>)
<% if (ctx.post.flags.length) { %><!--
--><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!--
@ -57,7 +58,7 @@
Search on
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>IQDB</a> &middot;
<a href='https://danbooru.donmai.us/posts?tags=md5:<%- ctx.post.checksumMD5 %>'>Danbooru</a> &middot;
<a href='https://www.google.com/searchbyimage?&image_url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
<a href='https://lens.google.com/uploadbyurl?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
</section>
<section class='social'>
@ -98,10 +99,10 @@
--><% if (ctx.canListPosts) { %><!--
--><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(tag.names[0])}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
--><% } %><!--
--><%- ctx.getPrettyName(tag.names[0]) %>&#32;<!--
--><%- ctx.getPrettyName(tag.names[0]) %><!--
--><% if (ctx.canListPosts) { %><!--
--></a><!--
--><% } %><!--
--><% } %>&#32;<!--
--><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
--></li><!--
--><% } %><!--

View File

@ -7,12 +7,28 @@
<span class='skip-duplicates'>
<%= ctx.makeCheckbox({
text: 'Skip duplicates',
text: 'Skip duplicate',
name: 'skip-duplicates',
checked: false,
}) %>
</span>
<span class='always-upload-similar'>
<%= ctx.makeCheckbox({
text: 'Force upload similar',
name: 'always-upload-similar',
checked: false,
}) %>
</span>
<span class='pause-remain-on-error'>
<%= ctx.makeCheckbox({
text: 'Pause on error',
name: 'pause-remain-on-error',
checked: true,
}) %>
</span>
<input type='button' value='Cancel' class='cancel'/>
</div>

View File

@ -61,6 +61,7 @@
text: 'Upload anonymously',
name: 'anonymous',
checked: ctx.uploadable.anonymous,
readonly: ctx.uploadable.forceAnonymous,
}) %>
</div>
<% } %>
@ -84,7 +85,7 @@
<div class='controls'>
<%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
<br/>
<%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation'}) %>
<%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation', checked: true}) %>
</div>
</li>
<% } %>

View File

@ -16,7 +16,6 @@
%><form class='horizontal bulk-edit bulk-edit-tags'><%
%><span class='append hint'>Tagging with:</span><%
%><a href class='mousetrap button append open'>Mass tag</a><%
%><wbr/><%
%><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
%><input class='mousetrap start' type='submit' value='Start tagging'/><%
%><a href class='mousetrap button append close'>Stop tagging</a><%
@ -28,4 +27,11 @@
%><a href class='mousetrap button append close'>Stop editing safety</a><%
%></form><%
%><% } %><%
%><% if (ctx.canBulkDelete) { %><%
%><form class='horizontal bulk-edit bulk-edit-delete'><%
%><a href class='mousetrap button append open'>Mass delete</a><%
%><input class='mousetrap start' type='submit' value='Delete selected posts'/><%
%><a href class='mousetrap button append close'>Stop deleting</a><%
%></form><%
%><% } %><%
%></div>

View File

@ -10,28 +10,27 @@
<span class='type' data-type='<%- post.type %>'>
<% if (post.type == 'video' || post.type == 'flash' || post.type == 'animation') { %>
<span class='icon'><i class='fa fa-film'></i></span>
<% } else { %>
<%- post.type %>
<% } %>
<%- post.type %>
</span>
<% if (post.score || post.favoriteCount || post.commentCount) { %>
<span class='stats'>
<% if (post.score) { %>
<span class='icon'>
<i class='fa fa-thumbs-up'></i>
<%- post.score %>
<span style="display: none">score:</span><%- post.score %>
</span>
<% } %>
<% if (post.favoriteCount) { %>
<span class='icon'>
<i class='fa fa-heart'></i>
<%- post.favoriteCount %>
<span style="display: none">favorites:</span><%- post.favoriteCount %>
</span>
<% } %>
<% if (post.commentCount) { %>
<span class='icon'>
<i class='fa fa-commenting'></i>
<%- post.commentCount %>
<span style="display: none">comments:</span><%- post.commentCount %>
</span>
<% } %>
</span>
@ -50,6 +49,10 @@
<% } %>
</span>
<% } %>
<% if (ctx.canBulkDelete && ctx.parameters && ctx.parameters.delete) { %>
<a href class='delete-flipper'>
</a>
<% } %>
</span>
</li>
<% } %>

View File

@ -28,7 +28,6 @@
name: 'dark-theme',
checked: ctx.browsingSettings.darkTheme,
}) %>
<p class='hint'>Changing this setting will require you to refresh the page for it to apply.</p>
</li>
<li>

View File

@ -12,7 +12,7 @@
<%= item.operation %>
<%= ctx.makeResourceLink(item.type, item.id) %>
<%= ctx.makeResourceLink(item.type, item.id, item.data) %>
</div>
<div class='details'><!--

View File

@ -2,6 +2,7 @@
<h1><%- ctx.getPrettyName(ctx.tag.names[0]) %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li data-name='view'><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(ctx.tag.names[0])}) %>'>View</a></li><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>'>Summary</a></li><!--
--><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'edit') %>'>Edit</a></li><!--

View File

@ -1,4 +1,10 @@
<div class='content-wrapper tag-summary'>
<section class='description'>
<%= ctx.makeMarkdown(ctx.tag.description || 'This tag has no description yet.') %>
<p>This tag has <a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(ctx.tag.names[0])}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
<hr/>
</section>
<section class='details'>
<section>
Category:
@ -32,10 +38,4 @@
--></ul>
</section>
</section>
<section class='description'>
<hr/>
<%= ctx.makeMarkdown(ctx.tag.description || 'This tag has no description yet.') %>
<p>This tag has <a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(ctx.tag.names[0])}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
</section>
</div>

View File

@ -398,7 +398,7 @@ class Api extends events.EventTarget {
if (data) {
if (files && Object.keys(files).length) {
req.attach("metadata", new Blob([JSON.stringify(data)]));
req.attach("metadata", new Blob([JSON.stringify(data)], { type: "application/json" }));
} else {
req.set("Content-Type", "application/json");
req.send(data);

View File

@ -17,7 +17,7 @@ class HomeController {
buildDate: config.meta.buildDate,
canListSnapshots: api.hasPrivilege("snapshots:list"),
canListPosts: api.hasPrivilege("posts:list"),
isDevelopmentMode: config.environment == "development"
isDevelopmentMode: config.environment == "development",
});
Info.get().then(

View File

@ -91,16 +91,16 @@ class PoolController {
_evtUpdate(e) {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.names !== undefined) {
if (e.detail.names !== undefined && e.detail.names !== null) {
e.detail.pool.names = e.detail.names;
}
if (e.detail.category !== undefined) {
if (e.detail.category !== undefined && e.detail.category !== null) {
e.detail.pool.category = e.detail.category;
}
if (e.detail.description !== undefined) {
if (e.detail.description !== undefined && e.detail.description !== null) {
e.detail.pool.description = e.detail.description;
}
if (e.detail.posts !== undefined) {
if (e.detail.posts !== undefined && e.detail.posts !== null) {
e.detail.pool.posts.clear();
for (let postId of e.detail.posts) {
e.detail.pool.posts.add(

View File

@ -43,6 +43,8 @@ class PoolListController {
this._headerView.addEventListener(
"submit",
(e) => this._evtSubmit(e),
);
this._headerView.addEventListener(
"navigate",
(e) => this._evtNavigate(e)
);

View File

@ -44,6 +44,7 @@ class PostListController {
enableSafety: api.safetyEnabled(),
canBulkEditTags: api.hasPrivilege("posts:bulk-edit:tags"),
canBulkEditSafety: api.hasPrivilege("posts:bulk-edit:safety"),
canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"),
bulkEdit: {
tags: this._bulkEditTags,
},
@ -52,6 +53,16 @@ class PostListController {
this._evtNavigate(e)
);
if (this._headerView._bulkDeleteEditor) {
this._headerView._bulkDeleteEditor.addEventListener(
"deleteSelectedPosts",
(e) => {
this._evtDeleteSelectedPosts(e);
}
);
}
this._postsMarkedForDeletion = [];
this._syncPageController();
}
@ -91,6 +102,38 @@ class PostListController {
e.detail.post.save().catch((error) => window.alert(error.message));
}
_evtMarkForDeletion(e) {
const postId = e.detail;
// Add or remove post from delete list
if (e.detail.delete) {
this._postsMarkedForDeletion.push(e.detail.post);
} else {
this._postsMarkedForDeletion = this._postsMarkedForDeletion.filter(
(x) => x.id != e.detail.post.id
);
}
}
_evtDeleteSelectedPosts(e) {
if (this._postsMarkedForDeletion.length == 0) return;
if (
confirm(
`Are you sure you want to delete ${this._postsMarkedForDeletion.length} posts?`
)
) {
Promise.all(
this._postsMarkedForDeletion.map((post) => post.delete())
)
.catch((error) => window.alert(error.message))
.then(() => {
this._postsMarkedForDeletion = [];
this._headerView._navigate();
});
}
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
@ -117,8 +160,10 @@ class PostListController {
canBulkEditSafety: api.hasPrivilege(
"posts:bulk-edit:safety"
),
canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"),
bulkEdit: {
tags: this._bulkEditTags,
markedForDeletion: this._postsMarkedForDeletion,
},
postFlow: settings.get().postFlow,
});
@ -128,6 +173,9 @@ class PostListController {
view.addEventListener("changeSafety", (e) =>
this._evtChangeSafety(e)
);
view.addEventListener("markForDeletion", (e) =>
this._evtMarkForDeletion(e)
);
return view;
},
});

View File

@ -169,22 +169,22 @@ class PostMainController extends BasePostController {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
const post = e.detail.post;
if (e.detail.safety !== undefined) {
if (e.detail.safety !== undefined && e.detail.safety !== null) {
post.safety = e.detail.safety;
}
if (e.detail.flags !== undefined) {
if (e.detail.flags !== undefined && e.detail.flags !== null) {
post.flags = e.detail.flags;
}
if (e.detail.relations !== undefined) {
if (e.detail.relations !== undefined && e.detail.relations !== null) {
post.relations = e.detail.relations;
}
if (e.detail.content !== undefined) {
if (e.detail.content !== undefined && e.detail.content !== null) {
post.newContent = e.detail.content;
}
if (e.detail.thumbnail !== undefined) {
if (e.detail.thumbnail !== undefined && e.detail.thumbnail !== null) {
post.newThumbnail = e.detail.thumbnail;
}
if (e.detail.source !== undefined) {
if (e.detail.source !== undefined && e.detail.source !== null) {
post.source = e.detail.source;
}
post.save().then(
@ -281,7 +281,16 @@ module.exports = (router) => {
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
ctx.controller = new PostMainController(ctx, true);
const canEditPosts = api.hasPrivilege("posts:edit");
if (canEditPosts) {
ctx.controller = new PostMainController(ctx, true);
} else {
router.show(uri.formatClientLink(
"post",
ctx.parameters.id,
ctx.parameters ? { query: ctx.parameters.query } : {}
));
}
});
router.enter(["post", ":id"], (ctx, next) => {
// restore parameters from history state

View File

@ -12,7 +12,7 @@ const PostUploadView = require("../views/post_upload_view.js");
const EmptyView = require("../views/empty_view.js");
const genericErrorMessage =
"One of the posts needs your attention; " +
"One or more posts needs your attention; " +
'click "resume upload" when you\'re ready.';
class PostUploadController {
@ -55,6 +55,7 @@ class PostUploadController {
_evtSubmit(e) {
this._view.disableForm();
this._view.clearMessages();
let anyFailures = false;
e.detail.uploadables
.reduce(
@ -62,11 +63,45 @@ class PostUploadController {
promise.then(() =>
this._uploadSinglePost(
uploadable,
e.detail.skipDuplicates
)
e.detail.skipDuplicates,
e.detail.alwaysUploadSimilar
).catch((error) => {
anyFailures = true;
if (error.uploadable) {
if (error.similarPosts) {
error.uploadable.lookalikes =
error.similarPosts;
this._view.updateUploadable(
error.uploadable
);
this._view.showInfo(
error.message,
error.uploadable
);
} else {
this._view.showError(
error.message,
error.uploadable
);
}
} else {
this._view.showError(
error.message,
uploadable
);
}
if (e.detail.pauseRemainOnError) {
return Promise.reject();
}
})
),
Promise.resolve()
)
.then(() => {
if (anyFailures) {
return Promise.reject();
}
})
.then(
() => {
this._view.clearMessages();
@ -75,31 +110,13 @@ class PostUploadController {
ctx.controller.showSuccess("Posts uploaded.");
},
(error) => {
if (error.uploadable) {
if (error.similarPosts) {
error.uploadable.lookalikes = error.similarPosts;
this._view.updateUploadable(error.uploadable);
this._view.showInfo(genericErrorMessage);
this._view.showInfo(
error.message,
error.uploadable
);
} else {
this._view.showError(genericErrorMessage);
this._view.showError(
error.message,
error.uploadable
);
}
} else {
this._view.showError(error.message);
}
this._view.showError(genericErrorMessage);
this._view.enableForm();
}
);
}
_uploadSinglePost(uploadable, skipDuplicates) {
_uploadSinglePost(uploadable, skipDuplicates, alwaysUploadSimilar) {
progress.start();
let reverseSearchPromise = Promise.resolve();
if (!uploadable.lookalikesConfirmed) {
@ -128,7 +145,10 @@ class PostUploadController {
}
// notify about similar posts
if (searchResult.similarPosts.length) {
if (
searchResult.similarPosts.length &&
!alwaysUploadSimilar
) {
let error = new Error(
`Found ${searchResult.similarPosts.length} similar ` +
"posts.\nYou can resume or discard this upload."

View File

@ -95,13 +95,13 @@ class TagController {
_evtUpdate(e) {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.names !== undefined) {
if (e.detail.names !== undefined && e.detail.names !== null) {
e.detail.tag.names = e.detail.names;
}
if (e.detail.category !== undefined) {
if (e.detail.category !== undefined && e.detail.category !== null) {
e.detail.tag.category = e.detail.category;
}
if (e.detail.description !== undefined) {
if (e.detail.description !== undefined && e.detail.description !== null) {
e.detail.tag.description = e.detail.description;
}
e.detail.tag.save().then(

View File

@ -31,9 +31,8 @@ class UserController {
userTokenPromise = UserToken.get(userName).then(
(userTokens) => {
return userTokens.map((token) => {
token.isCurrentAuthToken = api.isCurrentAuthToken(
token
);
token.isCurrentAuthToken =
api.isCurrentAuthToken(token);
return token;
});
},
@ -176,21 +175,21 @@ class UserController {
const isLoggedIn = api.isLoggedIn(e.detail.user);
const infix = isLoggedIn ? "self" : "any";
if (e.detail.name !== undefined) {
if (e.detail.name !== undefined && e.detail.name !== null) {
e.detail.user.name = e.detail.name;
}
if (e.detail.email !== undefined) {
if (e.detail.email !== undefined && e.detail.email !== null) {
e.detail.user.email = e.detail.email;
}
if (e.detail.rank !== undefined) {
if (e.detail.rank !== undefined && e.detail.rank !== null) {
e.detail.user.rank = e.detail.rank;
}
if (e.detail.password !== undefined) {
if (e.detail.password !== undefined && e.detail.password !== null) {
e.detail.user.password = e.detail.password;
}
if (e.detail.avatarStyle !== undefined) {
if (e.detail.avatarStyle !== undefined && e.detail.avatarStyle !== null) {
e.detail.user.avatarStyle = e.detail.avatarStyle;
if (e.detail.avatarContent) {
e.detail.user.avatarContent = e.detail.avatarContent;
@ -303,7 +302,7 @@ class UserController {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.note !== undefined) {
if (e.detail.note !== undefined && e.detail.note !== null) {
e.detail.userToken.note = e.detail.note;
}

View File

@ -45,9 +45,8 @@ class ExpanderControl {
// eslint-disable-next-line accessor-pairs
set title(newTitle) {
if (this._expanderNode) {
this._expanderNode.querySelector(
"header span"
).textContent = newTitle;
this._expanderNode.querySelector("header span").textContent =
newTitle;
}
}

View File

@ -48,6 +48,12 @@ class FileDropperControl extends events.EventTarget {
this._urlInputNode.addEventListener("keydown", (e) =>
this._evtUrlInputKeyDown(e)
);
this._urlInputNode.addEventListener("paste", (e) => {
// document.onpaste is used on the post-upload page.
// And this event is used on the post edit page.
if (document.getElementById("post-upload")) return;
this._evtPaste(e)
});
}
if (this._urlConfirmButtonNode) {
this._urlConfirmButtonNode.addEventListener("click", (e) =>
@ -55,6 +61,11 @@ class FileDropperControl extends events.EventTarget {
);
}
document.onpaste = (e) => {
if (!document.getElementById("post-upload")) return;
this._evtPaste(e)
}
this._originalHtml = this._dropperNode.innerHTML;
views.replaceContent(target, source);
}
@ -129,6 +140,17 @@ class FileDropperControl extends events.EventTarget {
this._emitFiles(e.dataTransfer.files);
}
_evtPaste(e) {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
const fileList = Array.from(items).map((x) => x.getAsFile()).filter(f => f);
if (!this._options.allowMultiple && fileList.length > 1) {
window.alert("Cannot select multiple files.");
} else if (fileList.length > 0) {
this._emitFiles(fileList);
}
}
_evtUrlInputKeyDown(e) {
if (e.which !== KEY_RETURN) {
return;

View File

@ -103,6 +103,30 @@ class PostContentControl {
}
_refreshSize() {
if (window.innerWidth <= 800) {
const buttons = document.querySelector(".sidebar > .buttons");
if (buttons) {
const content = document.querySelector(".content");
content.insertBefore(buttons, content.querySelector(".post-container + *"));
const afterControls = document.querySelector(".content > .after-mobile-controls");
if (afterControls) {
afterControls.parentElement.parentElement.appendChild(afterControls);
}
}
} else {
const buttons = document.querySelector(".content > .buttons");
if (buttons) {
const sidebar = document.querySelector(".sidebar");
sidebar.insertBefore(buttons, sidebar.firstElementChild);
}
const afterControls = document.querySelector(".content + .after-mobile-controls");
if (afterControls) {
document.querySelector(".content").appendChild(afterControls);
}
}
this._currentFitFunction();
}

View File

@ -4,6 +4,7 @@ const api = require("../api.js");
const events = require("../events.js");
const misc = require("../util/misc.js");
const views = require("../util/views.js");
const keyboard = require("../util/keyboard.js");
const Note = require("../models/note.js");
const Point = require("../models/point.js");
const TagInputControl = require("./tag_input_control.js");
@ -203,9 +204,8 @@ class PostEditSidebarControl extends events.EventTarget {
);
if (this._formNode) {
const inputNodes = this._formNode.querySelectorAll(
"input, textarea"
);
const inputNodes =
this._formNode.querySelectorAll("input, textarea");
for (let node of inputNodes) {
node.addEventListener("change", (e) =>
this.dispatchEvent(new CustomEvent("change"))
@ -225,10 +225,12 @@ class PostEditSidebarControl extends events.EventTarget {
});
}
this._tagControl.addEventListener("change", (e) => {
this.dispatchEvent(new CustomEvent("change"));
this._syncExpanderTitles();
});
if (this._tagControl) {
this._tagControl.addEventListener("change", (e) => {
this.dispatchEvent(new CustomEvent("change"));
this._syncExpanderTitles();
});
}
if (this._noteTextareaNode) {
this._noteTextareaNode.addEventListener("change", (e) =>
@ -242,6 +244,16 @@ class PostEditSidebarControl extends events.EventTarget {
this._syncExpanderTitles();
});
}
keyboard.bind(["command+s", "ctrl+s"], (e) => this._evtSubmit(e));
if (this._tagInputNode) {
const realTagInput = this._formNode.querySelector(".tag-input input");
keyboard.bindElement(realTagInput, ["command+s", "ctrl+s"], (e) => this._evtSubmit(e));
keyboard.bind("t", (e) => {
e.preventDefault();
realTagInput.focus();
});
}
}
_syncExpanderTitles() {
@ -428,7 +440,7 @@ class PostEditSidebarControl extends events.EventTarget {
: undefined,
thumbnail:
this._newPostThumbnail !== undefined
this._newPostThumbnail !== undefined && this._newPostThumbnail !== null
? this._newPostThumbnail
: undefined,

View File

@ -727,9 +727,8 @@ class PostNotesOverlayControl extends events.EventTarget {
}
_showNoteText(note) {
this._textNode.querySelector(
".wrapper"
).innerHTML = misc.formatMarkdown(note.text);
this._textNode.querySelector(".wrapper").innerHTML =
misc.formatMarkdown(note.text);
this._textNode.style.display = "block";
const bodyRect = document.body.getBoundingClientRect();
const noteRect = this._textNode.getBoundingClientRect();

View File

@ -196,9 +196,10 @@ class TagInputControl extends events.EventTarget {
const listItemNode = this._createListItemNode(tag);
if (!tag.category) {
listItemNode.classList.add("new");
}
if (source === SOURCE_IMPLICATION) {
} else if (source === SOURCE_IMPLICATION) {
listItemNode.classList.add("implication");
} else {
listItemNode.classList.add("added");
}
this._tagListNode.prependChild(listItemNode);
_fadeOutListItemNodeStatus(listItemNode);

View File

@ -4,12 +4,12 @@ const config = require("./config.js");
if (config.environment == "development") {
var ws = new WebSocket("ws://" + location.hostname + ":8080");
ws.addEventListener('open', function (event) {
ws.addEventListener("open", function (event) {
console.log("Live-reloading websocket connected.");
});
ws.addEventListener('message', (event) => {
ws.addEventListener("message", (event) => {
console.log(event);
if (event.data == 'reload'){
if (event.data == "reload") {
location.reload();
}
});
@ -42,6 +42,10 @@ const pools = require("./pools.js");
const api = require("./api.js");
const settings = require("./models/settings.js");
if (settings.get().darkTheme) {
document.body.classList.add("darktheme");
}
Promise.resolve()
.then(() => api.fetchConfig())
.then(

View File

@ -75,7 +75,7 @@ class Post extends events.EventTarget {
}
get sourceSplit() {
return this._source.split("\n");
return this._source.split("\n").filter((s) => s);
}
get canvasWidth() {
@ -271,7 +271,7 @@ class Post extends events.EventTarget {
if (this._newContent) {
files.content = this._newContent;
}
if (this._newThumbnail !== undefined) {
if (this._newThumbnail !== undefined && this._newThumbnail !== null) {
files.thumbnail = this._newThumbnail;
}
if (this._source !== this._orig._source) {

View File

@ -40,6 +40,11 @@ class Settings extends events.EventTarget {
save(newSettings, silent) {
newSettings = Object.assign(this.cache, newSettings);
localStorage.setItem("settings", JSON.stringify(newSettings));
if (newSettings.darkTheme) {
document.body.classList.add("darktheme");
} else {
document.body.classList.remove("darktheme");
}
this.cache = this._getFromLocalStorage();
if (silent !== true) {
this.dispatchEvent(

View File

@ -22,12 +22,21 @@ function bind(hotkey, func) {
return false;
}
function bindElement(element, hotkey, func) {
if (settings.get().keyboardShortcuts) {
mousetrap(element).bind(hotkey, func);
return true;
}
return false;
}
function unbind(hotkey) {
mousetrap.unbind(hotkey);
}
module.exports = {
bind: bind,
bindElement: bindElement,
unbind: unbind,
pause: () => {
paused = true;

View File

@ -54,7 +54,7 @@ class TildeWrapper extends BaseMarkdownWrapper {
// prevent ^#... from being treated as headers, due to tag permalinks
class TagPermalinkFixWrapper extends BaseMarkdownWrapper {
preprocess(text) {
return text.replace(/^#/g, "%%%#");
return text.replace(/^#(?=[a-zA-Z0-9_-])/g, "%%%#");
}
postprocess(text) {
@ -65,17 +65,6 @@ class TagPermalinkFixWrapper extends BaseMarkdownWrapper {
// post, user and tags permalinks
class EntityPermalinkWrapper extends BaseMarkdownWrapper {
preprocess(text) {
// URL-based permalinks
text = text.replace(new RegExp("\\b/post/(\\d+)/?\\b", "g"), "@$1");
text = text.replace(
new RegExp("\\b/tag/([a-zA-Z0-9_-]+?)/?", "g"),
"#$1"
);
text = text.replace(
new RegExp("\\b/user/([a-zA-Z0-9_-]+?)/?", "g"),
"+$1"
);
text = text.replace(
/(^|^\(|(?:[^\]])\(|[\s<>\[\]\)])([+#@][a-zA-Z0-9_-]+)/g,
"$1[$2]($2)"
@ -136,12 +125,8 @@ function createRenderer() {
const renderer = new marked.Renderer();
renderer.image = (href, title, alt) => {
let [
_,
url,
width,
height,
] = /^(.+?)(?:\s=\s*(\d*)\s*x\s*(\d*)\s*)?$/.exec(href);
let [_, url, width, height] =
/^(.+?)(?:\s=\s*(\d*)\s*x\s*(\d*)\s*)?$/.exec(href);
let res = '<img src="' + sanitize(url) + '" alt="' + sanitize(alt);
if (width) {
res += '" width="' + width;
@ -174,7 +159,7 @@ function formatMarkdown(text) {
for (let wrapper of wrappers) {
text = wrapper.preprocess(text);
}
text = marked(text, options);
text = marked.parse(text, options);
wrappers.reverse();
for (let wrapper of wrappers) {
text = wrapper.postprocess(text);
@ -200,7 +185,7 @@ function formatInlineMarkdown(text) {
for (let wrapper of wrappers) {
text = wrapper.preprocess(text);
}
text = marked.inlineLexer(text, [], options);
text = marked.parseInline(text, options);
wrappers.reverse();
for (let wrapper of wrappers) {
text = wrapper.postprocess(text);

View File

@ -5,7 +5,8 @@ const keyboard = require("../util/keyboard.js");
const views = require("./views.js");
function searchInputNodeFocusHelper(inputNode) {
keyboard.bind("q", () => {
keyboard.bind("q", (e) => {
e.preventDefault();
inputNode.focus();
inputNode.setSelectionRange(
inputNode.value.length,

View File

@ -111,7 +111,7 @@ function makeSelect(options) {
}
function makeInput(options) {
options.value = options.value || "";
options.value = options.value === 0 ? 0 : options.value || "";
return _makeLabel(options) + makeElement("input", options);
}
@ -209,13 +209,13 @@ function makePostLink(id, includeHash) {
}
function makeTagLink(name, includeHash, includeCount, tag) {
const category = tag ? tag.category : "unknown";
const category = tag && tag.category ? tag.category : "unknown";
let text = misc.getPrettyName(name);
if (includeHash === true) {
text = "#" + text;
}
if (includeCount === true) {
text += " (" + (tag ? tag.postCount : 0) + ")";
text += " (" + (tag && tag.postCount ? tag.postCount : 0) + ")";
}
return api.hasPrivilege("tags:view")
? makeElement(
@ -234,15 +234,15 @@ function makeTagLink(name, includeHash, includeCount, tag) {
}
function makePoolLink(id, includeHash, includeCount, pool, name) {
const category = pool ? pool.category : "unknown";
const category = pool && pool.category ? pool.category : "unknown";
let text = misc.getPrettyName(
name ? name : pool ? pool.names[0] : "unknown"
name ? name : pool && pool.names ? pool.names[0] : "pool " + id
);
if (includeHash === true) {
text = "#" + text;
}
if (includeCount === true) {
text += " (" + (pool ? pool.postCount : 0) + ")";
text += " (" + (pool && pool.postCount ? pool.postCount : 0) + ")";
}
return api.hasPrivilege("pools:view")
? makeElement(
@ -261,10 +261,10 @@ function makePoolLink(id, includeHash, includeCount, pool, name) {
}
function makeUserLink(user) {
let text = makeThumbnail(user ? user.avatarUrl : null);
let text = makeThumbnail(user ? user.avatarUrl : "img/favicon.png");
text += user && user.name ? misc.escapeHtml(user.name) : "Anonymous";
const link =
user && api.hasPrivilege("users:view")
user && user.name && api.hasPrivilege("users:view")
? makeElement(
"a",
{ href: uri.formatClientLink("user", user.name) },
@ -300,6 +300,8 @@ function _serializeElement(name, attributes) {
attributes[key] === undefined
) {
return "";
} else if (attributes[key] === 0) {
return `${key}="0"`;
}
const attribute = misc.escapeHtml(attributes[key] || "");
return `${key}="${attribute}"`;
@ -321,6 +323,7 @@ function emptyContent(target) {
}
function replaceContent(target, source) {
if (!target) return;
emptyContent(target);
if (source instanceof NodeList) {
for (let child of [...source]) {

View File

@ -58,6 +58,11 @@ class PoolMergeView extends events.EventTarget {
_evtSubmit(e) {
e.preventDefault();
if (!this._targetPoolId) {
this.clearMessages();
this.showError("You must select a pool name from autocomplete.");
return;
}
this.dispatchEvent(
new CustomEvent("submit", {
detail: {

View File

@ -25,9 +25,8 @@ class PostMainView {
views.replaceContent(this._hostNode, sourceNode);
views.syncScrollPosition();
const topNavigationNode = document.body.querySelector(
"#top-navigation"
);
const topNavigationNode =
document.body.querySelector("#top-navigation");
this._postContentControl = new PostContentControl(
postContainerNode,

View File

@ -1,6 +1,7 @@
"use strict";
const events = require("../events.js");
const api = require("../api.js");
const views = require("../util/views.js");
const FileDropperControl = require("../controls/file_dropper_control.js");
@ -21,6 +22,7 @@ function _mimeTypeToPostType(mimeType) {
"image/heic": "image",
"video/mp4": "video",
"video/webm": "video",
"video/quicktime": "video",
}[mimeType] || "unknown"
);
}
@ -34,7 +36,8 @@ class Uploadable extends events.EventTarget {
this.flags = [];
this.tags = [];
this.relations = [];
this.anonymous = false;
this.anonymous = !api.isLoggedIn();
this.forceAnonymous = !api.isLoggedIn();
}
destroy() {}
@ -118,6 +121,7 @@ class Url extends Uploadable {
heif: "image/heif",
heic: "image/heic",
mp4: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
};
for (let extension of Object.keys(mime)) {
@ -283,7 +287,7 @@ class PostUploadView extends events.EventTarget {
for (let uploadable of this._uploadables) {
this._updateUploadableFromDom(uploadable);
}
this._submitButtonNode.value = "Resume upload";
this._submitButtonNode.value = "Resume";
this._emit("submit");
}
@ -358,6 +362,10 @@ class PostUploadView extends events.EventTarget {
detail: {
uploadables: this._uploadables,
skipDuplicates: this._skipDuplicatesCheckboxNode.checked,
alwaysUploadSimilar:
this._alwaysUploadSimilarCheckboxNode.checked,
pauseRemainOnError:
this._pauseRemainOnErrorCheckboxNode.checked,
},
})
);
@ -421,6 +429,18 @@ class PostUploadView extends events.EventTarget {
return this._hostNode.querySelector("form [name=skip-duplicates]");
}
get _alwaysUploadSimilarCheckboxNode() {
return this._hostNode.querySelector(
"form [name=always-upload-similar]"
);
}
get _pauseRemainOnErrorCheckboxNode() {
return this._hostNode.querySelector(
"form [name=pause-remain-on-error]"
);
}
get _submitButtonNode() {
return this._hostNode.querySelector("form [type=submit]");
}

View File

@ -60,12 +60,14 @@ class BulkSafetyEditor extends BulkEditor {
e.preventDefault();
this.toggleOpen(true);
this.dispatchEvent(new CustomEvent("open", { detail: {} }));
misc.enableExitConfirmation();
}
_evtCloseLinkClick(e) {
e.preventDefault();
this.toggleOpen(false);
this.dispatchEvent(new CustomEvent("close", { detail: {} }));
misc.disableExitConfirmation();
}
}
@ -130,6 +132,7 @@ class BulkTagEditor extends BulkEditor {
this.toggleOpen(true);
this.focus();
this.dispatchEvent(new CustomEvent("open", { detail: {} }));
misc.enableExitConfirmation();
}
_evtCloseLinkClick(e) {
@ -138,6 +141,37 @@ class BulkTagEditor extends BulkEditor {
this.toggleOpen(false);
this.blur();
this.dispatchEvent(new CustomEvent("close", { detail: {} }));
misc.disableExitConfirmation();
}
}
class BulkDeleteEditor extends BulkEditor {
constructor(hostNode) {
super(hostNode);
this._hostNode.addEventListener("submit", (e) =>
this._evtFormSubmit(e)
);
}
_evtFormSubmit(e) {
e.preventDefault();
this.dispatchEvent(
new CustomEvent("deleteSelectedPosts", { detail: {} })
);
}
_evtOpenLinkClick(e) {
e.preventDefault();
this.toggleOpen(true);
this.dispatchEvent(new CustomEvent("open", { detail: {} }));
misc.enableExitConfirmation();
}
_evtCloseLinkClick(e) {
e.preventDefault();
this.toggleOpen(false);
this.dispatchEvent(new CustomEvent("close", { detail: {} }));
misc.disableExitConfirmation();
}
}
@ -186,6 +220,13 @@ class PostsHeaderView extends events.EventTarget {
this._bulkEditors.push(this._bulkSafetyEditor);
}
if (this._bulkEditDeleteNode) {
this._bulkDeleteEditor = new BulkDeleteEditor(
this._bulkEditDeleteNode
);
this._bulkEditors.push(this._bulkDeleteEditor);
}
for (let editor of this._bulkEditors) {
editor.addEventListener("submit", (e) => {
this._navigate();
@ -204,6 +245,8 @@ class PostsHeaderView extends events.EventTarget {
this._openBulkEditor(this._bulkTagEditor);
} else if (ctx.parameters.safety && this._bulkSafetyEditor) {
this._openBulkEditor(this._bulkSafetyEditor);
} else if (ctx.parameters.delete && this._bulkDeleteEditor) {
this._openBulkEditor(this._bulkDeleteEditor);
}
}
@ -227,6 +270,10 @@ class PostsHeaderView extends events.EventTarget {
return this._hostNode.querySelector(".bulk-edit-safety");
}
get _bulkEditDeleteNode() {
return this._hostNode.querySelector(".bulk-edit-delete");
}
_openBulkEditor(editor) {
editor.toggleOpen(true);
this._hideBulkEditorsExcept(editor);
@ -253,9 +300,8 @@ class PostsHeaderView extends events.EventTarget {
e.target.classList.toggle("disabled");
const safety = e.target.getAttribute("data-safety");
let browsingSettings = settings.get();
browsingSettings.listPosts[safety] = !browsingSettings.listPosts[
safety
];
browsingSettings.listPosts[safety] =
!browsingSettings.listPosts[safety];
settings.save(browsingSettings, true);
this.dispatchEvent(
new CustomEvent("navigate", {
@ -294,6 +340,10 @@ class PostsHeaderView extends events.EventTarget {
this._bulkSafetyEditor && this._bulkSafetyEditor.opened
? "1"
: null;
parameters.delete =
this._bulkDeleteEditor && this._bulkDeleteEditor.opened
? "1"
: null;
this.dispatchEvent(
new CustomEvent("navigate", { detail: { parameters: parameters } })
);

View File

@ -39,6 +39,13 @@ class PostsPageView extends events.EventTarget {
);
}
}
const deleteFlipperNode = this._getDeleteFlipperNode(listItemNode);
if (deleteFlipperNode) {
deleteFlipperNode.addEventListener("click", (e) =>
this._evtBulkToggleDeleteClick(e, post)
);
}
}
this._syncBulkEditorsHighlights();
@ -56,6 +63,10 @@ class PostsPageView extends events.EventTarget {
return listItemNode.querySelector(".safety-flipper");
}
_getDeleteFlipperNode(listItemNode) {
return listItemNode.querySelector(".delete-flipper");
}
_evtPostChange(e) {
const listItemNode = this._postIdToListItemNode[e.detail.post.id];
for (let node of listItemNode.querySelectorAll("[data-disabled]")) {
@ -99,6 +110,20 @@ class PostsPageView extends events.EventTarget {
);
}
_evtBulkToggleDeleteClick(e, post) {
e.preventDefault();
const linkNode = e.target;
linkNode.classList.toggle("delete");
this.dispatchEvent(
new CustomEvent("markForDeletion", {
detail: {
post,
delete: linkNode.classList.contains("delete"),
},
})
);
}
_syncBulkEditorsHighlights() {
for (let listItemNode of this._listItemNodes) {
const postId = listItemNode.getAttribute("data-post-id");
@ -123,6 +148,16 @@ class PostsPageView extends events.EventTarget {
);
}
}
const deleteFlipperNode = this._getDeleteFlipperNode(listItemNode);
if (deleteFlipperNode) {
deleteFlipperNode.classList.toggle(
"delete",
this._ctx.bulkEdit.markedForDeletion.some(
(x) => x.id == postId
)
);
}
}
}
}

View File

@ -29,7 +29,7 @@ function _formatBasicChange(diff, text) {
return lines;
}
function _makeResourceLink(type, id) {
function _makeResourceLink(type, id, data) {
if (type === "post") {
return views.makePostLink(id, true);
} else if (type === "tag") {
@ -37,7 +37,7 @@ function _makeResourceLink(type, id) {
} else if (type === "tag_category") {
return 'category "' + id + '"';
} else if (type === "pool") {
return views.makePoolLink(id, true);
return views.makePoolLink(id, false, false, data);
}
}

View File

@ -72,9 +72,8 @@ class UserTokenView extends events.EventTarget {
_evtDelete(e) {
e.preventDefault();
const userToken = this._tokens[
parseInt(e.target.getAttribute("data-token-id"))
];
const userToken =
this._tokens[parseInt(e.target.getAttribute("data-token-id"))];
this.dispatchEvent(
new CustomEvent("delete", {
detail: {
@ -110,9 +109,8 @@ class UserTokenView extends events.EventTarget {
_evtChangeNoteClick(e) {
e.preventDefault();
const userToken = this._tokens[
parseInt(e.target.getAttribute("data-token-id"))
];
const userToken =
this._tokens[parseInt(e.target.getAttribute("data-token-id"))];
const text = window.prompt(
"Please enter the new name:",
userToken.note !== null ? userToken.note : undefined

274
client/package-lock.json generated
View File

@ -10,7 +10,7 @@
"font-awesome": "^4.7.0",
"ios-inner-height": "^1.0.3",
"js-cookie": "^2.2.0",
"marked": "^0.7.0",
"marked": "^4.0.10",
"mousetrap": "^1.6.2",
"nprogress": "^0.2.0",
"superagent": "^3.8.3"
@ -28,7 +28,7 @@
"jimp": "^0.13.0",
"pretty-error": "^3.0.3",
"stylus": "^0.54.8",
"terser": "^3.7.7",
"terser": "^4.8.1",
"underscore": "^1.12.1",
"watchify": "^4.0.0",
"ws": "^7.4.6"
@ -476,24 +476,6 @@
"node": ">= 8"
}
},
"node_modules/array-filter": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz",
"integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=",
"dev": true
},
"node_modules/array-map": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz",
"integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=",
"dev": true
},
"node_modules/array-reduce": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz",
"integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=",
"dev": true
},
"node_modules/asn1.js": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
@ -1506,16 +1488,15 @@
"dev": true
},
"node_modules/cached-path-relative": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.2.tgz",
"integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.1.0.tgz",
"integrity": "sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==",
"dev": true
},
"node_modules/call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
@ -1682,9 +1663,9 @@
"dev": true
},
"node_modules/cookiejar": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz",
"integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA=="
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="
},
"node_modules/core-js": {
"version": "2.5.7",
@ -1850,9 +1831,9 @@
}
},
"node_modules/decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"dev": true,
"engines": {
"node": ">=0.10"
@ -2254,8 +2235,7 @@
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/get-assigned-identifiers": {
"version": "1.2.0",
@ -2267,7 +2247,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
"integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
@ -2351,7 +2330,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.1"
},
@ -2384,7 +2362,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@ -2859,9 +2836,9 @@
"dev": true
},
"node_modules/jpeg-js": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.0.tgz",
"integrity": "sha512-960VHmtN1vTpasX/1LupLohdP5odwAT7oK/VSm6mW0M58LbrBnowLAPWAZhWGhDAGjzbMnPXZxzB/QYgBwkN0w==",
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"dev": true
},
"node_modules/js-cookie": {
@ -2997,14 +2974,14 @@
"dev": true
},
"node_modules/marked": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz",
"integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==",
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.0.10.tgz",
"integrity": "sha512-+QvuFj0nGgO970fySghXGmuw+Fd0gD2x3+MqCWLIPf5oxdv1Ka6b2q+z9RP01P/IaKPMEramy+7cNy/Lw8c3hw==",
"bin": {
"marked": "bin/marked"
"marked": "bin/marked.js"
},
"engines": {
"node": ">=0.10.0"
"node": ">= 12"
}
},
"node_modules/md5.js": {
@ -3108,9 +3085,9 @@
}
},
"node_modules/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true
},
"node_modules/mkdirp": {
@ -3224,7 +3201,6 @@
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
"integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -3385,9 +3361,9 @@
}
},
"node_modules/path-parse": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"node_modules/path-platform": {
@ -3507,11 +3483,17 @@
"dev": true
},
"node_modules/qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
"integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
"dependencies": {
"side-channel": "^1.0.4"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/querystring": {
@ -3770,15 +3752,22 @@
}
},
"node_modules/shell-quote": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz",
"integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=",
"dev": true,
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz",
"integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==",
"dev": true
},
"node_modules/side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
"dependencies": {
"array-filter": "~0.0.0",
"array-map": "~0.0.0",
"array-reduce": "~0.0.0",
"jsonify": "~0.0.0"
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",
"object-inspect": "^1.9.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-concat": {
@ -3983,12 +3972,6 @@
"minimist": "^1.1.0"
}
},
"node_modules/subarg/node_modules/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"dev": true
},
"node_modules/superagent": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz",
@ -4028,26 +4011,26 @@
}
},
"node_modules/terser": {
"version": "3.7.7",
"resolved": "https://registry.npmjs.org/terser/-/terser-3.7.7.tgz",
"integrity": "sha512-RRLIxE7S52vSOI9cEbOaisgBd2y6MNgfg2ihUkidsFnuP1eDmZ79+lBWbyvgfFTAc/r8nSjL0k3cpZDDIYiYiA==",
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
"integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
"dev": true,
"dependencies": {
"commander": "~2.14.1",
"commander": "^2.20.0",
"source-map": "~0.6.1",
"source-map-support": "~0.5.6"
"source-map-support": "~0.5.12"
},
"bin": {
"terser": "bin/uglifyjs"
"terser": "bin/terser"
},
"engines": {
"node": ">=0.8.0"
"node": ">=6.0.0"
}
},
"node_modules/terser/node_modules/commander": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
},
"node_modules/terser/node_modules/source-map": {
@ -4060,9 +4043,9 @@
}
},
"node_modules/terser/node_modules/source-map-support": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.6.tgz",
"integrity": "sha512-N4KXEz7jcKqPf2b2vZF11lQIz9W5ZMuUcIOGj243lduidkf2fjkVKJS9vNxVWn3u/uxX38AcE8U9nnH9FPcq+g==",
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"dependencies": {
"buffer-from": "^1.0.0",
@ -5047,24 +5030,6 @@
"picomatch": "^2.0.4"
}
},
"array-filter": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz",
"integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=",
"dev": true
},
"array-map": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz",
"integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=",
"dev": true
},
"array-reduce": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz",
"integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=",
"dev": true
},
"asn1.js": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
@ -6053,16 +6018,15 @@
"dev": true
},
"cached-path-relative": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.2.tgz",
"integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.1.0.tgz",
"integrity": "sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==",
"dev": true
},
"call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"dev": true,
"requires": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
@ -6211,9 +6175,9 @@
"dev": true
},
"cookiejar": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz",
"integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA=="
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="
},
"core-js": {
"version": "2.5.7",
@ -6363,9 +6327,9 @@
}
},
"decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"dev": true
},
"define-properties": {
@ -6697,8 +6661,7 @@
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"get-assigned-identifiers": {
"version": "1.2.0",
@ -6710,7 +6673,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
"integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
"dev": true,
"requires": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
@ -6778,7 +6740,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"requires": {
"function-bind": "^1.1.1"
}
@ -6801,8 +6762,7 @@
"has-symbols": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
"dev": true
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
},
"hash-base": {
"version": "3.0.4",
@ -7158,9 +7118,9 @@
}
},
"jpeg-js": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.0.tgz",
"integrity": "sha512-960VHmtN1vTpasX/1LupLohdP5odwAT7oK/VSm6mW0M58LbrBnowLAPWAZhWGhDAGjzbMnPXZxzB/QYgBwkN0w==",
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"dev": true
},
"js-cookie": {
@ -7280,9 +7240,9 @@
"dev": true
},
"marked": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz",
"integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg=="
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.0.10.tgz",
"integrity": "sha512-+QvuFj0nGgO970fySghXGmuw+Fd0gD2x3+MqCWLIPf5oxdv1Ka6b2q+z9RP01P/IaKPMEramy+7cNy/Lw8c3hw=="
},
"md5.js": {
"version": "1.3.4",
@ -7364,9 +7324,9 @@
}
},
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true
},
"mkdirp": {
@ -7466,8 +7426,7 @@
"object-inspect": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
"integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==",
"dev": true
"integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw=="
},
"object-keys": {
"version": "1.1.1",
@ -7607,9 +7566,9 @@
"dev": true
},
"path-parse": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"path-platform": {
@ -7705,9 +7664,12 @@
"dev": true
},
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
"integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
"requires": {
"side-channel": "^1.0.4"
}
},
"querystring": {
"version": "0.2.0",
@ -7936,15 +7898,19 @@
}
},
"shell-quote": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz",
"integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=",
"dev": true,
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz",
"integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==",
"dev": true
},
"side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
"requires": {
"array-filter": "~0.0.0",
"array-map": "~0.0.0",
"array-reduce": "~0.0.0",
"jsonify": "~0.0.0"
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",
"object-inspect": "^1.9.0"
}
},
"simple-concat": {
@ -8116,14 +8082,6 @@
"dev": true,
"requires": {
"minimist": "^1.1.0"
},
"dependencies": {
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"dev": true
}
}
},
"superagent": {
@ -8159,20 +8117,20 @@
}
},
"terser": {
"version": "3.7.7",
"resolved": "https://registry.npmjs.org/terser/-/terser-3.7.7.tgz",
"integrity": "sha512-RRLIxE7S52vSOI9cEbOaisgBd2y6MNgfg2ihUkidsFnuP1eDmZ79+lBWbyvgfFTAc/r8nSjL0k3cpZDDIYiYiA==",
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
"integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
"dev": true,
"requires": {
"commander": "~2.14.1",
"commander": "^2.20.0",
"source-map": "~0.6.1",
"source-map-support": "~0.5.6"
"source-map-support": "~0.5.12"
},
"dependencies": {
"commander": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
},
"source-map": {
@ -8182,9 +8140,9 @@
"dev": true
},
"source-map-support": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.6.tgz",
"integrity": "sha512-N4KXEz7jcKqPf2b2vZF11lQIz9W5ZMuUcIOGj243lduidkf2fjkVKJS9vNxVWn3u/uxX38AcE8U9nnH9FPcq+g==",
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"requires": {
"buffer-from": "^1.0.0",

View File

@ -3,14 +3,15 @@
"private": true,
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch"
"watch": "node build.js --watch",
"build-container": "docker build -t szurubooru/client:dev ."
},
"dependencies": {
"dompurify": "^2.0.17",
"font-awesome": "^4.7.0",
"ios-inner-height": "^1.0.3",
"js-cookie": "^2.2.0",
"marked": "^0.7.0",
"marked": "^4.0.10",
"mousetrap": "^1.6.2",
"nprogress": "^0.2.0",
"superagent": "^3.8.3"
@ -28,7 +29,7 @@
"jimp": "^0.13.0",
"pretty-error": "^3.0.3",
"stylus": "^0.54.8",
"terser": "^3.7.7",
"terser": "^4.8.1",
"underscore": "^1.12.1",
"watchify": "^4.0.0",
"ws": "^7.4.6"

View File

@ -37,6 +37,7 @@
- [Creating post](#creating-post)
- [Updating post](#updating-post)
- [Getting post](#getting-post)
- [Getting around post](#getting-around-post)
- [Deleting post](#deleting-post)
- [Merging posts](#merging-posts)
- [Rating post](#rating-post)
@ -53,7 +54,7 @@
- [Deleting pool category](#deleting-pool-category)
- [Setting default pool category](#setting-default-pool-category)
- Pools
- [Listing pools](#listing-pool)
- [Listing pools](#listing-pools)
- [Creating pool](#creating-pool)
- [Updating pool](#updating-pool)
- [Getting pool](#getting-pool)
@ -164,9 +165,9 @@ way. The files, however, should be passed as regular fields appended with a
accepts a file named `content`, the client should pass
`{"contentUrl":"http://example.com/file.jpg"}` as a part of the JSON message
body. When creating or updating post content using this method, the server can
also be configured to employ [youtube-dl](https://github.com/ytdl-org/youtube-dl)
to download content from popular sites such as youtube, gfycat, etc. Access to
youtube-dl can be configured with the `'uploads:use_downloader'` permission
also be configured to employ [yt-dlp](https://github.com/yt-dlp/yt-dlp) to
download content from popular sites such as youtube, gfycat, etc. Access to
yt-dlp can be configured with the `'uploads:use_downloader'` permission
Finally, in some cases the user might want to reuse one file between the
requests to save the bandwidth (for example, reverse search + consecutive
@ -322,7 +323,7 @@ data.
{
"name": <name>,
"color": <color>,
"order": <order> // optional
"order": <order>
}
```
@ -788,7 +789,7 @@ data.
| `fav-time` | alias of `fav-date` |
| `feature-date` | featured at given date |
| `feature-time` | alias of `feature-time` |
| `safety` | having given safety. `<value>` can be either `safe`, `sketchy` (or `questionable`) or `unsafe`. |
| `safety` | having given safety. `<value>` can be either `safe`, `sketchy` or `unsafe`. |
| `rating` | alias of `safety` |
**Sort style tokens**
@ -951,6 +952,29 @@ data.
Retrieves information about an existing post.
## Getting around post
- **Request**
`GET /post/<id>/around`
- **Output**
```json5
{
"prev": <post-resource>,
"next": <post-resource>
}
```
- **Errors**
- the post does not exist
- privileges are too low
- **Description**
Retrieves information about posts that are before or after an existing post.
## Deleting post
- **Request**
@ -1365,7 +1389,7 @@ data.
## Creating pool
- **Request**
`POST /pools/create`
`POST /pool`
- **Input**
@ -2467,7 +2491,7 @@ One file together with its metadata posted to the site.
## Micro post
**Description**
A [post resource](#post) stripped down to `name` and `thumbnailUrl` fields.
A [post resource](#post) stripped down to `id` and `thumbnailUrl` fields.
## Note
**Description**

View File

@ -1,5 +1,5 @@
This assumes that you have Docker (version 17.05 or greater)
and Docker Compose (version 1.6.0 or greater) already installed.
This assumes that you have Docker (version 19.03 or greater)
and the Docker Compose CLI (version 1.27.0 or greater) already installed.
### Prepare things
@ -34,33 +34,79 @@ and Docker Compose (version 1.6.0 or greater) already installed.
Read the comments to guide you. Note that `.env` should be in the root
directory of this repository.
### Running the Application
4. Pull the containers:
Download containers:
```console
user@host:szuru$ docker-compose pull
```
This pulls the latest containers from docker.io:
```console
user@host:szuru$ docker compose pull
```
For first run, it is recommended to start the database separately:
```console
user@host:szuru$ docker-compose up -d sql
```
If you have modified the application's source and would like to manually
build it, follow the instructions in [**Building**](#Building) instead,
then read here once you're done.
To start all containers:
```console
user@host:szuru$ docker-compose up -d
```
5. Run it!
To view/monitor the application logs:
```console
user@host:szuru$ docker-compose logs -f
# (CTRL+C to exit)
```
For first run, it is recommended to start the database separately:
```console
user@host:szuru$ docker compose up -d sql
```
To start all containers:
```console
user@host:szuru$ docker compose up -d
```
To view/monitor the application logs:
```console
user@host:szuru$ docker compose logs -f
# (CTRL+C to exit)
```
### Building
1. Edit `docker-compose.yml` to tell Docker to build instead of pull containers:
```diff yaml
...
server:
- image: szurubooru/server:latest
+ build: server
...
client:
- image: szurubooru/client:latest
+ build: client
...
```
You can choose to build either one from source.
2. Build the containers:
```console
user@host:szuru$ docker compose build
```
That will attempt to build both containers, but you can specify `client`
or `server` to make it build only one.
If `docker compose build` spits out:
```
ERROR: Service 'server' failed to build: failed to parse platform : "" is an invalid component of "": platform specifier component must match "^[A-Za-z0-9_-]+$": invalid argument
```
...you will need to export Docker BuildKit flags:
```console
user@host:szuru$ export DOCKER_BUILDKIT=1; export COMPOSE_DOCKER_CLI_BUILD=1
```
...and run `docker compose build` again.
*Note: If your changes are not taking effect in your builds, consider building
with `--no-cache`.*
To stop all containers:
```console
user@host:szuru$ docker-compose down
```
### Additional Features
@ -71,7 +117,7 @@ user@host:szuru$ docker-compose down
run from docker:
```console
user@host:szuru$ docker-compose run server ./szuru-admin --help
user@host:szuru$ docker compose run server ./szuru-admin --help
```
will give you a breakdown on all available commands.

View File

@ -10,6 +10,12 @@ BUILD_INFO=latest
# otherwise the port specified here will be publicly accessible
PORT=8080
# How many waitress threads to start
# 4 is the default amount of threads. If you experience performance
# degradation with a large number of posts, increasing this may
# improve performance, since waitress is most likely clogging up with Tasks.
THREADS=4
# URL base to run szurubooru under
# See "Additional Features" section in INSTALL.md
BASE_URL=/

View File

@ -1,9 +1,7 @@
## Example Docker Compose configuration
##
## Use this as a template to set up docker-compose, or as guide to set up other
## Use this as a template to set up docker compose, or as guide to set up other
## orchestration services
version: '2'
services:
server:
@ -21,6 +19,7 @@ services:
#POSTGRES_DB: defaults to same as POSTGRES_USER
#POSTGRES_PORT: 5432
#LOG_SQL: 0 (1 for verbose SQL logs)
THREADS:
volumes:
- "${MOUNT_DATA}:/data"
- "./server/config.yaml:/opt/app/config.yaml"

View File

@ -7,8 +7,13 @@ WORKDIR /opt/app
RUN apk --no-cache add \
python3 \
python3-dev \
ffmpeg \
py3-pip \
build-base \
libheif \
libheif-dev \
libavif \
libavif-dev \
ffmpeg \
# from requirements.txt:
py3-yaml \
py3-psycopg2 \
@ -18,26 +23,21 @@ RUN apk --no-cache add \
py3-pillow \
py3-pynacl \
py3-tz \
py3-pyrfc3339 \
build-base \
&& apk --no-cache add \
libheif \
libavif \
libheif-dev \
libavif-dev \
&& pip3 install --no-cache-dir --disable-pip-version-check \
alembic \
py3-pyrfc3339
RUN pip3 install --no-cache-dir --disable-pip-version-check \
"alembic>=0.8.5" \
"coloredlogs==5.0" \
youtube_dl \
pillow-avif-plugin \
pyheif-pillow-opener \
&& apk --no-cache del py3-pip
"pyheif==0.6.1" \
"heif-image-plugin>=0.3.2" \
yt-dlp \
"pillow-avif-plugin~=1.1.0"
RUN apk --no-cache del py3-pip
COPY ./ /opt/app/
RUN rm -rf /opt/app/szurubooru/tests
FROM prereqs as testing
FROM --platform=$BUILDPLATFORM prereqs as testing
WORKDIR /opt/app
RUN apk --no-cache add \
@ -83,6 +83,9 @@ ARG PORT=6666
ENV PORT=${PORT}
EXPOSE ${PORT}
ARG THREADS=4
ENV THREADS=${THREADS}
VOLUME ["/data/"]
ARG DOCKER_REPO

View File

@ -115,6 +115,7 @@ privileges:
'posts:favorite': regular
'posts:bulk-edit:tags': power
'posts:bulk-edit:safety': power
'posts:bulk-edit:delete': power
'tags:create': regular
'tags:edit:names': power

View File

@ -4,5 +4,5 @@ cd /opt/app
alembic upgrade head
echo "Starting szurubooru API on port ${PORT}"
exec waitress-serve-3 --port ${PORT} szurubooru.facade:app
echo "Starting szurubooru API on port ${PORT} - Running on ${THREADS} threads"
exec waitress-serve-3 --port ${PORT} --threads ${THREADS} szurubooru.facade:app

View File

@ -1,7 +0,0 @@
#!/bin/sh
docker build \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg SOURCE_COMMIT \
--build-arg DOCKER_REPO \
-f $DOCKERFILE_PATH -t $IMAGE_NAME .

View File

@ -1,19 +0,0 @@
#!/bin/sh
add_tag() {
echo "Also tagging image as ${DOCKER_REPO}:${1}"
docker tag $IMAGE_NAME $DOCKER_REPO:$1
docker push $DOCKER_REPO:$1
}
CLOSEST_VER=$(git describe --tags --abbrev=0)
CLOSEST_MAJOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f1)
CLOSEST_MINOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f2)
add_tag "${CLOSEST_MAJOR_VER}-edge"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}-edge"
if git describe --exact-match --abbrev=0 2> /dev/null; then
add_tag "${CLOSEST_MAJOR_VER}"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}"
fi

View File

@ -1,8 +0,0 @@
#!/bin/sh
set -e
docker run --rm \
-t $(docker build --target testing -q .) \
--color=no szurubooru/
exit $?

View File

@ -1,14 +1,15 @@
alembic>=0.8.5
pyyaml>=3.11
psycopg2-binary>=2.6.1
SQLAlchemy>=1.0.12, <1.4
coloredlogs==5.0
certifi>=2017.11.5
coloredlogs==5.0
heif-image-plugin==0.3.2
numpy>=1.8.2
pillow-avif-plugin~=1.1.0
pillow>=4.3.0
psycopg2-binary>=2.6.1
pyheif==0.6.1
pynacl>=1.2.1
pytz>=2018.3
pyRFC3339>=1.0
pillow-avif-plugin>=1.1.0
pyheif-pillow-opener>=0.1.0
youtube_dl
pytz>=2018.3
pyyaml>=3.11
SQLAlchemy>=1.0.12, <1.4
yt-dlp

View File

@ -91,6 +91,15 @@ def reset_filenames() -> None:
rename_in_dir("posts/custom-thumbnails/")
def regenerate_thumbnails() -> None:
for post in db.session.query(model.Post).all():
print("Generating tumbnail for post %d ..." % post.post_id, end="\r")
try:
postfuncs.generate_post_thumbnail(post)
except Exception:
pass
def main() -> None:
parser_top = ArgumentParser(
description="Collection of CLI commands for an administrator to use",
@ -114,6 +123,12 @@ def main() -> None:
help="reset and rename the content and thumbnail "
"filenames in case of a lost/changed secret key",
)
parser.add_argument(
"--regenerate-thumbnails",
action="store_true",
help="regenerate the thumbnails for posts if the "
"thumbnail files are missing",
)
command = parser_top.parse_args()
try:
@ -123,6 +138,8 @@ def main() -> None:
check_audio()
elif command.reset_filenames:
reset_filenames()
elif command.regenerate_thumbnails:
regenerate_thumbnails()
except errors.BaseError as e:
print(e, file=stderr)

View File

@ -21,7 +21,7 @@ _search_executor = search.Executor(_search_executor_config)
def _get_post_id(params: Dict[str, str]) -> int:
try:
return int(params["post_id"])
except TypeError:
except (TypeError, ValueError):
raise posts.InvalidPostIdError(
"Invalid post ID: %r." % params["post_id"]
)

View File

@ -21,7 +21,7 @@ def _merge(left: Dict, right: Dict) -> Dict:
return left
def _docker_config() -> Dict:
def _container_config() -> Dict:
if "TEST_ENVIRONMENT" not in os.environ:
for key in ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_HOST"]:
if key not in os.environ:
@ -33,7 +33,7 @@ def _docker_config() -> Dict:
"show_sql": int(os.getenv("LOG_SQL", 0)),
"data_url": os.getenv("DATA_URL", "data/"),
"data_dir": "/data/",
"database": "postgres://%(user)s:%(pass)s@%(host)s:%(port)d/%(db)s"
"database": "postgresql://%(user)s:%(pass)s@%(host)s:%(port)d/%(db)s"
% {
"user": os.getenv("POSTGRES_USER"),
"pass": os.getenv("POSTGRES_PASSWORD"),
@ -49,6 +49,15 @@ def _file_config(filename: str) -> Dict:
return yaml.load(handle.read(), Loader=yaml.SafeLoader) or {}
def _running_inside_container() -> bool:
env = os.environ.keys()
return (
os.path.exists("/.dockerenv")
or "KUBERNETES_SERVICE_HOST" in env
or "container" in env # set by lxc/podman
)
def _read_config() -> Dict:
ret = _file_config("config.yaml.dist")
if os.path.isfile("config.yaml"):
@ -57,8 +66,8 @@ def _read_config() -> Dict:
logger.warning(
"'config.yaml' should be a file, not a directory, skipping"
)
if os.path.exists("/.dockerenv"):
ret = _merge(ret, _docker_config())
if _running_inside_container():
ret = _merge(ret, _container_config())
return ret

View File

@ -135,7 +135,7 @@ _live_migrations = (
def create_app() -> Callable[[Any, Any], Any]:
""" Create a WSGI compatible App object. """
"""Create a WSGI compatible App object."""
validate_config()
coloredlogs.install(fmt="[%(asctime)-15s] %(name)s %(message)s")
if config.config["debug"]:

View File

@ -25,7 +25,7 @@ RANK_MAP = OrderedDict(
def get_password_hash(salt: str, password: str) -> Tuple[str, int]:
""" Retrieve argon2id password hash. """
"""Retrieve argon2id password hash."""
return (
pwhash.argon2id.str(
(config.config["secret"] + salt + password).encode("utf8")
@ -37,7 +37,7 @@ def get_password_hash(salt: str, password: str) -> Tuple[str, int]:
def get_sha256_legacy_password_hash(
salt: str, password: str
) -> Tuple[str, int]:
""" Retrieve old-style sha256 password hash. """
"""Retrieve old-style sha256 password hash."""
digest = hashlib.sha256()
digest.update(config.config["secret"].encode("utf8"))
digest.update(salt.encode("utf8"))
@ -46,7 +46,7 @@ def get_sha256_legacy_password_hash(
def get_sha1_legacy_password_hash(salt: str, password: str) -> Tuple[str, int]:
""" Retrieve old-style sha1 password hash. """
"""Retrieve old-style sha1 password hash."""
digest = hashlib.sha1()
digest.update(b"1A2/$_4xVa")
digest.update(salt.encode("utf8"))
@ -125,7 +125,7 @@ def verify_privilege(user: model.User, privilege_name: str) -> None:
def generate_authentication_token(user: model.User) -> str:
""" Generate nonguessable challenge (e.g. links in password reminder). """
"""Generate nonguessable challenge (e.g. links in password reminder)."""
assert user
digest = hashlib.md5()
digest.update(config.config["secret"].encode("utf8"))

View File

@ -4,12 +4,10 @@ from datetime import datetime
from io import BytesIO
from typing import Any, Callable, List, Optional, Set, Tuple
import HeifImagePlugin
import numpy as np
from PIL import Image
import pillow_avif
import pyheif
from pyheif_pillow_opener import register_heif_opener
register_heif_opener()
from PIL import Image
from szurubooru import config, errors
@ -44,7 +42,7 @@ def _preprocess_image(content: bytes) -> NpMatrix:
try:
img = Image.open(BytesIO(content))
return np.asarray(img.convert("L"), dtype=np.uint8)
except IOError:
except (IOError, ValueError):
raise errors.ProcessingError(
"Unable to generate a signature hash " "for this image."
)

View File

@ -6,6 +6,9 @@ import shlex
import subprocess
from io import BytesIO
from typing import List
import HeifImagePlugin
import pillow_avif
from PIL import Image as PILImage
from szurubooru import errors
@ -17,10 +20,15 @@ logger = logging.getLogger(__name__)
def convert_heif_to_png(content: bytes) -> bytes:
img = PILImage.open(BytesIO(content))
img_byte_arr = BytesIO()
img.save(img_byte_arr, format='PNG')
img.save(img_byte_arr, format="PNG")
return img_byte_arr.getvalue()
def check_for_loop(content: bytes) -> bytes:
img = PILImage.open(BytesIO(content))
return "loop" in img.info
class Image:
def __init__(self, content: bytes) -> None:
self.content = content
@ -276,10 +284,10 @@ class Image:
proc = subprocess.Popen(
cli,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
stdin=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
out, err = proc.communicate(input=self.content)
out, err = proc.communicate()
if proc.returncode != 0:
logger.warning(
"Failed to execute ffmpeg command (cli=%r, err=%r)",

View File

@ -36,9 +36,12 @@ def get_mime_type(content: bytes) -> str:
if content[0:4] == b"\x1A\x45\xDF\xA3":
return "video/webm"
if content[4:12] in (b"ftypisom", b"ftypiso5", b"ftypmp42", b"ftypM4V "):
if content[4:12] in (b"ftypisom", b"ftypiso5", b"ftypiso6", b"ftypmp42", b"ftypM4V "):
return "video/mp4"
if content[4:12] == b"ftypqt ":
return "video/quicktime"
return "application/octet-stream"
@ -54,6 +57,7 @@ def get_extension(mime_type: str) -> Optional[str]:
"image/heif": "heif",
"image/heic": "heic",
"video/mp4": "mp4",
"video/quicktime": "mov",
"video/webm": "webm",
"application/octet-stream": "dat",
}
@ -65,7 +69,12 @@ def is_flash(mime_type: str) -> bool:
def is_video(mime_type: str) -> bool:
return mime_type.lower() in ("application/ogg", "video/mp4", "video/webm")
return mime_type.lower() in (
"application/ogg",
"video/mp4",
"video/quicktime",
"video/webm",
)
def is_image(mime_type: str) -> bool:
@ -88,6 +97,7 @@ def is_animated_gif(content: bytes) -> bool:
and len(re.findall(pattern, content)) > 1
)
def is_heif(mime_type: str) -> bool:
return mime_type.lower() in (
"image/heif",

View File

@ -39,13 +39,20 @@ def download(url: str, use_video_downloader: bool = False) -> bytes:
length_tally = 0
try:
with urllib.request.urlopen(request) as handle:
while (chunk := handle.read(_dl_chunk_size)) :
while chunk := handle.read(_dl_chunk_size):
length_tally += len(chunk)
if length_tally > config.config["max_dl_filesize"]:
raise DownloadTooLargeError(url)
raise DownloadTooLargeError(
"Download target exceeds maximum. (%d)"
% (config.config["max_dl_filesize"]),
extra_fields={"URL": url},
)
content_buffer += chunk
except urllib.error.HTTPError as ex:
raise DownloadError(url) from ex
raise DownloadError(
"Download target returned HTTP %d. (%s)" % (ex.code, ex.reason),
extra_fields={"URL": url},
) from ex
if (
youtube_dl_error
@ -57,7 +64,7 @@ def download(url: str, use_video_downloader: bool = False) -> bytes:
def _get_youtube_dl_content_url(url: str) -> str:
cmd = ["youtube-dl", "--format", "best", "--no-playlist"]
cmd = ["yt-dlp", "--format", "best", "--no-playlist"]
if config.config["user_agent"]:
cmd.extend(["--user-agent", config.config["user_agent"]])
cmd.extend(["--get-url", url])
@ -69,7 +76,8 @@ def _get_youtube_dl_content_url(url: str) -> str:
)
except subprocess.CalledProcessError:
raise errors.ThirdPartyError(
"Could not extract content location from %s" % (url)
"Could not extract content location from URL.",
extra_fields={"URL": url},
) from None

View File

@ -531,8 +531,10 @@ def generate_alternate_formats(
def get_default_flags(content: bytes) -> List[str]:
assert content
ret = []
if mime.is_video(mime.get_mime_type(content)):
ret.append(model.Post.FLAG_LOOP)
if mime.is_animated_gif(content):
if images.check_for_loop(content):
ret.append(model.Post.FLAG_LOOP)
elif mime.is_video(mime.get_mime_type(content)):
if images.Image(content).check_for_sound():
ret.append(model.Post.FLAG_SOUND)
return ret

View File

@ -83,12 +83,12 @@ def flip(source: Dict[Any, Any]) -> Dict[Any, Any]:
def is_valid_email(email: Optional[str]) -> bool:
""" Return whether given email address is valid or empty. """
"""Return whether given email address is valid or empty."""
return not email or re.match(r"^[^@]*@[^@]*\.[^@]*$", email) is not None
class dotdict(dict):
""" dot.notation access to dictionary attributes. """
"""dot.notation access to dictionary attributes."""
def __getattr__(self, attr: str) -> Any:
return self.get(attr)
@ -98,7 +98,7 @@ class dotdict(dict):
def parse_time_range(value: str) -> Tuple[datetime, datetime]:
""" Return tuple containing min/max time for given text representation. """
"""Return tuple containing min/max time for given text representation."""
one_day = timedelta(days=1)
one_second = timedelta(seconds=1)
almost_one_day = one_day - one_second

View File

@ -7,7 +7,7 @@ from szurubooru.rest.errors import HttpBadRequest
def _authenticate_basic_auth(username: str, password: str) -> model.User:
""" Try to authenticate user. Throw AuthError for invalid users. """
"""Try to authenticate user. Throw AuthError for invalid users."""
user = users.get_user_by_name(username)
if not auth.is_valid_password(user, password):
raise errors.AuthError("Invalid password.")
@ -17,7 +17,7 @@ def _authenticate_basic_auth(username: str, password: str) -> model.User:
def _authenticate_token(
username: str, token: str
) -> Tuple[model.User, model.UserToken]:
""" Try to authenticate user. Throw AuthError for invalid users. """
"""Try to authenticate user. Throw AuthError for invalid users."""
user = users.get_user_by_name(username)
user_token = user_tokens.get_by_user_and_token(user, token)
if not auth.is_valid_token(user_token):
@ -72,7 +72,7 @@ def _get_user(ctx: rest.Context, bump_login: bool) -> Optional[model.User]:
def process_request(ctx: rest.Context) -> None:
""" Bind the user to request. Update last login time if needed. """
"""Bind the user to request. Update last login time if needed."""
bump_login = ctx.get_param_as_bool("bump-login", default=False)
auth_user = _get_user(ctx, bump_login)
if auth_user:

View File

@ -11,7 +11,7 @@ from szurubooru.rest import context, errors, middleware, routes
def _json_serializer(obj: Any) -> str:
""" JSON serializer for objects not serializable by default JSON code """
"""JSON serializer for objects not serializable by default JSON code"""
if isinstance(obj, datetime):
serial = obj.isoformat("T") + "Z"
return serial

View File

@ -32,9 +32,13 @@ def _type_transformer(value: str) -> str:
def _safety_transformer(value: str) -> str:
available_values = {
"safe": model.Post.SAFETY_SAFE,
"s": model.Post.SAFETY_SAFE,
"sketchy": model.Post.SAFETY_SKETCHY,
"questionable": model.Post.SAFETY_SKETCHY,
"q": model.Post.SAFETY_SKETCHY,
"unsafe": model.Post.SAFETY_UNSAFE,
"explicit": model.Post.SAFETY_UNSAFE,
"e": model.Post.SAFETY_UNSAFE,
}
return search_util.enum_transformer(available_values, value)
@ -122,6 +126,34 @@ def _pool_filter(
)(query, criterion, negated)
def _category_filter(
query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool
) -> SaQuery:
assert criterion
# Step 1. find the id for the category
q1 = db.session.query(model.TagCategory.tag_category_id).filter(
model.TagCategory.name == criterion.value
)
# Step 2. find the tags with that category
q2 = db.session.query(model.Tag.tag_id).filter(
model.Tag.category_id.in_(q1)
)
# Step 3. find all posts that have at least one of those tags
q3 = db.session.query(model.PostTag.post_id).filter(
model.PostTag.tag_id.in_(q2)
)
# Step 4. profit
expr = model.Post.post_id.in_(q3)
if negated:
expr = ~expr
return query.filter(expr)
class PostSearchConfig(BaseSearchConfig):
def __init__(self) -> None:
self.user = None # type: Optional[model.User]
@ -349,6 +381,7 @@ class PostSearchConfig(BaseSearchConfig):
),
),
(["pool"], _pool_filter),
(["category"], _category_filter),
]
)

View File

@ -145,8 +145,9 @@ def test_trying_to_update_without_privileges(
)
@pytest.mark.parametrize("type", ["suggestions", "implications"])
def test_trying_to_create_tags_without_privileges(
config_injector, context_factory, tag_factory, user_factory
config_injector, context_factory, tag_factory, user_factory, type
):
tag = tag_factory(names=["tag"])
db.session.add(tag)
@ -165,16 +166,7 @@ def test_trying_to_create_tags_without_privileges(
with pytest.raises(errors.AuthError):
api.tag_api.update_tag(
context_factory(
params={"suggestions": ["tag1", "tag2"], "version": 1},
user=user_factory(rank=model.User.RANK_REGULAR),
),
{"tag_name": "tag"},
)
db.session.rollback()
with pytest.raises(errors.AuthError):
api.tag_api.update_tag(
context_factory(
params={"implications": ["tag1", "tag2"], "version": 1},
params={type: ["tag1", "tag2"], "version": 1},
user=user_factory(rank=model.User.RANK_REGULAR),
),
{"tag_name": "tag"},

Binary file not shown.

View File

@ -43,14 +43,26 @@ def query_logger(pytestconfig):
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
@pytest.yield_fixture(scope="function", autouse=True)
def session(query_logger, postgresql_db):
@pytest.fixture(scope="function", autouse=True)
def session(query_logger, transacted_postgresql_db):
db.session = transacted_postgresql_db.session
transacted_postgresql_db.create_table(*model.Base.metadata.sorted_tables)
try:
yield transacted_postgresql_db.session
finally:
transacted_postgresql_db.reset_db()
@pytest.fixture(scope="function")
def nontransacted_session(query_logger, postgresql_db):
old_db_session = db.session
db.session = postgresql_db.session
postgresql_db.create_table(*model.Base.metadata.sorted_tables)
try:
yield postgresql_db.session
finally:
postgresql_db.reset_db()
db.session = old_db_session
@pytest.fixture

View File

@ -7,6 +7,7 @@ from szurubooru.func import mime
"input_path,expected_mime_type",
[
("mp4.mp4", "video/mp4"),
("mov.mov", "video/quicktime"),
("webm.webm", "video/webm"),
("flash.swf", "application/x-shockwave-flash"),
("png.png", "image/png"),
@ -35,6 +36,7 @@ def test_get_mime_type_for_empty_file():
[
("video/mp4", "mp4"),
("video/webm", "webm"),
("video/quicktime", "mov"),
("application/x-shockwave-flash", "swf"),
("image/png", "png"),
("image/jpeg", "jpg"),
@ -70,6 +72,8 @@ def test_is_flash(input_mime_type, expected_state):
("VIDEO/WEBM", True),
("video/mp4", True),
("VIDEO/MP4", True),
("video/quicktime", True),
("VIDEO/QUICKTIME", True),
("video/anything_else", False),
("application/ogg", True),
("not a video", False),

View File

@ -1,3 +1,5 @@
import os
import pytest
from szurubooru import errors
@ -16,6 +18,9 @@ def inject_config(tmpdir, config_injector):
)
@pytest.mark.skipif(
"TEST_NET" not in os.environ, reason="Network tests skipped by default."
)
def test_download():
url = "http://info.cern.ch/hypertext/WWW/TheProject.html"
@ -62,6 +67,9 @@ def test_download():
assert actual_content == expected_content
@pytest.mark.skipif(
"TEST_NET" not in os.environ, reason="Network tests skipped by default."
)
@pytest.mark.parametrize(
"url",
[
@ -74,6 +82,9 @@ def test_too_large_download(url):
net.download(url, use_video_downloader=True)
@pytest.mark.skipif(
"TEST_NET" not in os.environ, reason="Network tests skipped by default."
)
@pytest.mark.parametrize(
"url,expected_sha1",
[
@ -96,6 +107,9 @@ def test_content_download(url, expected_sha1):
assert get_sha1(actual_content) == expected_sha1
@pytest.mark.skipif(
"TEST_NET" not in os.environ, reason="Network tests skipped by default."
)
def test_bad_content_downlaod():
url = "http://info.cern.ch/hypertext/WWW/TheProject.html"
with pytest.raises(errors.ThirdPartyError):
@ -108,11 +122,13 @@ def test_no_webhooks(config_injector):
assert len(res) == 0
@pytest.mark.skipif(
"TEST_NET" not in os.environ, reason="Network tests skipped by default."
)
@pytest.mark.parametrize(
"webhook,status_code",
[
("https://postman-echo.com/post", 200),
("http://localhost/", 400),
("https://postman-echo.com/get", 400),
],
)
@ -121,6 +137,9 @@ def test_single_webhook(config_injector, webhook, status_code):
assert ret == status_code
@pytest.mark.skipif(
"TEST_NET" not in os.environ, reason="Network tests skipped by default."
)
def test_multiple_webhooks(config_injector):
config_injector(
{

View File

@ -1,7 +1,7 @@
from datetime import datetime
from unittest.mock import patch
import pytest
import pytest # noqa: F401
from szurubooru import db, model
from szurubooru.func import snapshots, users
@ -144,46 +144,6 @@ def test_create(tag_factory, user_factory):
assert results[0].data == "mocked"
def test_modify_saves_non_empty_diffs(post_factory, user_factory):
if "sqlite" in db.session.get_bind().driver:
pytest.xfail(
"SQLite doesn't support transaction isolation, "
"which is required to retrieve original entity"
)
post = post_factory()
post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="old")]
user = user_factory()
db.session.add_all([post, user])
db.session.commit()
post.source = "new source"
post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="new")]
db.session.flush()
with patch("szurubooru.func.snapshots._post_to_webhooks"):
snapshots.modify(post, user)
db.session.flush()
results = db.session.query(model.Snapshot).all()
assert len(results) == 1
assert results[0].data == {
"type": "object change",
"value": {
"source": {
"type": "primitive change",
"old-value": None,
"new-value": "new source",
},
"notes": {
"type": "list change",
"removed": [
{"polygon": [[0, 0], [0, 1], [1, 1]], "text": "old"}
],
"added": [
{"polygon": [[0, 0], [0, 1], [1, 1]], "text": "new"}
],
},
},
}
def test_modify_doesnt_save_empty_diffs(tag_factory, user_factory):
tag = tag_factory(names=["dummy"])
user = user_factory()

View File

@ -0,0 +1,59 @@
from unittest.mock import patch
import pytest
from szurubooru import db, model
from szurubooru.func import snapshots
@pytest.fixture(autouse=True)
def session(query_logger, postgresql_db):
"""
Override db session for this specific test section only
"""
db.session = postgresql_db.session
postgresql_db.create_table(*model.Base.metadata.sorted_tables)
try:
yield postgresql_db.session
finally:
postgresql_db.reset_db()
def test_modify_saves_non_empty_diffs(post_factory, user_factory):
if "sqlite" in db.session.get_bind().driver:
pytest.xfail(
"SQLite doesn't support transaction isolation, "
"which is required to retrieve original entity"
)
post = post_factory()
post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="old")]
user = user_factory()
db.session.add_all([post, user])
db.session.commit()
post.source = "new source"
post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="new")]
db.session.flush()
with patch("szurubooru.func.snapshots._post_to_webhooks"):
snapshots.modify(post, user)
db.session.flush()
results = db.session.query(model.Snapshot).all()
assert len(results) == 1
assert results[0].data == {
"type": "object change",
"value": {
"source": {
"type": "primitive change",
"old-value": None,
"new-value": "new source",
},
"notes": {
"type": "list change",
"removed": [
{"polygon": [[0, 0], [0, 1], [1, 1]], "text": "old"}
],
"added": [
{"polygon": [[0, 0], [0, 1], [1, 1]], "text": "new"}
],
},
},
}

View File

@ -107,17 +107,16 @@ def test_update_category_name_reusing_other_name(
tag_categories.update_category_name(category, "NAME")
@pytest.mark.parametrize("name", ["name", "NAME"])
def test_update_category_name_reusing_own_name(
config_injector, tag_category_factory
config_injector, tag_category_factory, name
):
config_injector({"tag_category_name_regex": ".*"})
for name in ["name", "NAME"]:
category = tag_category_factory(name="name")
db.session.add(category)
db.session.flush()
tag_categories.update_category_name(category, name)
assert category.name == name
db.session.rollback()
category = tag_category_factory(name="name")
db.session.add(category)
db.session.flush()
tag_categories.update_category_name(category, name)
assert category.name == name
def test_update_category_color_with_empty_string(tag_category_factory):

Some files were not shown because too many files have changed in this diff Show More