diff --git a/client/css/banned-posts.styl b/client/css/banned-posts.styl
new file mode 100644
index 00000000..331f35bf
--- /dev/null
+++ b/client/css/banned-posts.styl
@@ -0,0 +1,29 @@
+@import colors
+
+.content-wrapper.banned-posts
+ width: 100%
+ max-width: 45em
+ table
+ border-spacing: 0
+ width: 100%
+ tr.default td
+ background: $default-banned-post-background-color
+ td, th
+ padding: .4em
+ &.color
+ input[type=text]
+ width: 8em
+ &.usages
+ text-align: center
+ &.remove, &.set-default
+ white-space: pre
+ th
+ white-space: nowrap
+ &:first-child
+ padding-left: 0
+ &:last-child
+ padding-right: 0
+ tfoot
+ display: none
+ form
+ width: auto
diff --git a/client/html/banned_post_entry.tpl b/client/html/banned_post_entry.tpl
new file mode 100644
index 00000000..87ec0f2a
--- /dev/null
+++ b/client/html/banned_post_entry.tpl
@@ -0,0 +1,13 @@
+
+
+ <%- ctx.postBan.checksum %>
+ |
+
+ <%= ctx.makeRelativeTime(ctx.postBan.time) %>
+ |
+ <% if (ctx.canDelete) { %>
+
+ Unban
+ |
+ <% } %>
+
diff --git a/client/html/banned_post_list.tpl b/client/html/banned_post_list.tpl
new file mode 100644
index 00000000..a27aa0b3
--- /dev/null
+++ b/client/html/banned_post_list.tpl
@@ -0,0 +1,25 @@
+
diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl
index 07dcf6f8..9e3641c8 100644
--- a/client/html/post_edit_sidebar.tpl
+++ b/client/html/post_edit_sidebar.tpl
@@ -108,7 +108,7 @@
<% } %>
- <% if (ctx.canFeaturePosts || ctx.canDeletePosts || ctx.canMergePosts) { %>
+ <% if (ctx.canFeaturePosts || ctx.canDeletePosts || ctx.canMergePosts || ctx.canBanPosts) { %>
<% if (ctx.canFeaturePosts) { %>
@@ -120,6 +120,9 @@
<% if (ctx.canDeletePosts) { %>
- Delete this post
<% } %>
+ <% if (ctx.canBanPosts) { %>
+ - Ban this post
+ <% } %>
<% } %>
diff --git a/client/js/controllers/banned_post_controller.js b/client/js/controllers/banned_post_controller.js
new file mode 100644
index 00000000..1daf7a86
--- /dev/null
+++ b/client/js/controllers/banned_post_controller.js
@@ -0,0 +1,59 @@
+"use strict";
+
+const api = require("../api.js");
+const BannedPostList = require("../models/banned_post_list.js");
+const topNavigation = require("../models/top_navigation.js");
+const BannedPostsView = require("../views/banned_posts_view.js");
+const EmptyView = require("../views/empty_view.js");
+
+class BannedPostController {
+ constructor() {
+ if (!api.hasPrivilege("posts:ban:list")) {
+ this._view = new EmptyView();
+ this._view.showError(
+ "You don't have privileges to view banned posts."
+ );
+ return;
+ }
+
+ topNavigation.activate("banned-posts");
+ topNavigation.setTitle("Listing banned posts");
+ BannedPostList.get().then(
+ (response) => {
+ this._bannedPosts = response.results;
+ this._view = new BannedPostsView({
+ bannedPosts: this._bannedPosts,
+ canDelete: api.hasPrivilege("poolCategories:delete")
+ });
+ this._view.addEventListener("submit", (e) =>
+ this._evtSubmit(e)
+ );
+ },
+ (error) => {
+ this._view = new EmptyView();
+ this._view.showError(error.message);
+ }
+ );
+ }
+
+ _evtSubmit(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ this._bannedPosts.save().then(
+ () => {
+ this._view.enableForm();
+ this._view.showSuccess("Changes saved.");
+ },
+ (error) => {
+ this._view.enableForm();
+ this._view.showError(error.message);
+ }
+ );
+ }
+}
+
+module.exports = (router) => {
+ router.enter(["banned-posts"], (ctx, next) => {
+ ctx.controller = new BannedPostController(ctx, next);
+ });
+};
diff --git a/client/js/controllers/post_main_controller.js b/client/js/controllers/post_main_controller.js
index bd338129..46d5b16b 100644
--- a/client/js/controllers/post_main_controller.js
+++ b/client/js/controllers/post_main_controller.js
@@ -88,6 +88,9 @@ class PostMainController extends BasePostController {
this._view.sidebarControl.addEventListener("delete", (e) =>
this._evtDeletePost(e)
);
+ this._view.sidebarControl.addEventListener("ban", (e) =>
+ this._evtBanPost(e)
+ );
this._view.sidebarControl.addEventListener("merge", (e) =>
this._evtMergePost(e)
);
@@ -165,6 +168,22 @@ class PostMainController extends BasePostController {
);
}
+ _evtBanPost(e) {
+ this._view.sidebarControl.disableForm();
+ this._view.sidebarControl.clearMessages();
+ e.detail.post.ban().then(
+ () => {
+ misc.disableExitConfirmation();
+ const ctx = router.show(uri.formatClientLink("posts"));
+ ctx.controller.showSuccess("Post banned.");
+ },
+ (error) => {
+ this._view.sidebarControl.showError(error.message);
+ this._view.sidebarControl.enableForm();
+ }
+ );
+ }
+
_evtUpdatePost(e) {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
diff --git a/client/js/controllers/top_navigation_controller.js b/client/js/controllers/top_navigation_controller.js
index fbc5399d..da90959a 100644
--- a/client/js/controllers/top_navigation_controller.js
+++ b/client/js/controllers/top_navigation_controller.js
@@ -53,6 +53,9 @@ class TopNavigationController {
if (!api.hasPrivilege("pools:list")) {
topNavigation.hide("pools");
}
+ if (!api.hasPrivilege("posts:ban:list")) {
+ topNavigation.hide("banned-posts");
+ }
if (api.isLoggedIn()) {
if (!api.hasPrivilege("users:create:any")) {
topNavigation.hide("register");
diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js
index 3b1c16e7..938088b8 100644
--- a/client/js/controls/post_edit_sidebar_control.js
+++ b/client/js/controls/post_edit_sidebar_control.js
@@ -46,6 +46,7 @@ class PostEditSidebarControl extends events.EventTarget {
"posts:create:anonymous"
),
canDeletePosts: api.hasPrivilege("posts:delete"),
+ canBanPosts: api.hasPrivilege("posts:ban"),
canFeaturePosts: api.hasPrivilege("posts:feature"),
canMergePosts: api.hasPrivilege("posts:merge"),
})
@@ -186,6 +187,12 @@ class PostEditSidebarControl extends events.EventTarget {
);
}
+ if (this._banLinkNode) {
+ this._banLinkNode.addEventListener("click", (e) =>
+ this._evtBanClick(e)
+ );
+ }
+
this._postNotesOverlayControl.addEventListener("blur", (e) =>
this._evtNoteBlur(e)
);
@@ -301,6 +308,19 @@ class PostEditSidebarControl extends events.EventTarget {
}
}
+ _evtBanClick(e) {
+ e.preventDefault();
+ if (confirm("Are you sure you want to ban this post?")) {
+ this.dispatchEvent(
+ new CustomEvent("ban", {
+ detail: {
+ post: this._post,
+ },
+ })
+ );
+ }
+ }
+
_evtNoteTextChangeRequest(e) {
if (this._editedNote) {
this._editedNote.text = this._noteTextareaNode.value;
@@ -517,6 +537,11 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector(".management .delete");
}
+ get _banLinkNode() {
+ return this._formNode.querySelector(".management .ban");
+ }
+
+
get _addNoteLinkNode() {
return this._formNode.querySelector(".notes .add");
}
diff --git a/client/js/main.js b/client/js/main.js
index c5bdc537..a89de2e1 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -83,6 +83,7 @@ Promise.resolve()
controllers.push(
require("./controllers/user_registration_controller.js")
);
+ controllers.push(require("./controllers/banned_post_controller.js"));
// 404 controller needs to be registered last
controllers.push(require("./controllers/not_found_controller.js"));
diff --git a/client/js/models/banned_post.js b/client/js/models/banned_post.js
new file mode 100644
index 00000000..cfa80c38
--- /dev/null
+++ b/client/js/models/banned_post.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const api = require("../api.js");
+const uri = require("../util/uri.js");
+const events = require("../events.js");
+
+class BannedPost extends events.EventTarget {
+ constructor() {
+ super();
+ this._checksum = "";
+ this._time = new Date();
+ }
+
+ get checksum() {
+ return this._checksum;
+ }
+
+ get time() {
+ return this._time;
+ }
+
+ set checksum(value) {
+ this._checksum = value;
+ }
+
+ set time(value) {
+ this._time = value;
+ }
+
+ static fromResponse(response) {
+ const ret = new BannedPost();
+ ret._updateFromResponse(response);
+ return ret;
+ }
+
+ delete() {
+ return api
+ .delete(uri.formatApiLink("post-ban", this._checksum))
+ .then((response) => {
+ this.dispatchEvent(
+ new CustomEvent("delete", {
+ detail: {
+ bannedPost: this,
+ },
+ })
+ );
+ return Promise.resolve();
+ });
+ }
+
+ _updateFromResponse(response) {
+ this._checksum = response.checksum;
+ this.time = response.time;
+ }
+}
+
+module.exports = BannedPost;
diff --git a/client/js/models/banned_post_list.js b/client/js/models/banned_post_list.js
new file mode 100644
index 00000000..61ac3a27
--- /dev/null
+++ b/client/js/models/banned_post_list.js
@@ -0,0 +1,47 @@
+const api = require("../api.js");
+const uri = require("../util/uri.js");
+const AbstractList = require("./abstract_list.js");
+const BannedPost = require("./banned_post.js");
+
+class BannedPostList extends AbstractList {
+ constructor() {
+ super();
+ this._deletedBans = [];
+ this.addEventListener("remove", (e) => this._evtBannedPostDeleted(e));
+ }
+
+ static get() {
+ return api
+ .get(uri.formatApiLink("post-ban"))
+ .then((response) => {
+ return Promise.resolve(
+ Object.assign({}, response, {
+ results: BannedPostList.fromResponse(
+ response.results
+ ),
+ })
+ );
+ });
+ }
+
+ save() {
+ let promises = [];
+ for (let bannedPost of this._deletedBans) {
+ promises.push(bannedPost.delete());
+ }
+
+ return Promise.all(promises).then((response) => {
+ this._deletedBans = [];
+ return Promise.resolve();
+ });
+ }
+
+ _evtBannedPostDeleted(e) {
+ this._deletedBans.push(e.detail.bannedPost);
+ }
+}
+
+BannedPostList._itemClass = BannedPost;
+BannedPostList._itemName = "bannedPost";
+
+module.exports = BannedPostList;
diff --git a/client/js/models/post.js b/client/js/models/post.js
index 01f81bf1..23274068 100644
--- a/client/js/models/post.js
+++ b/client/js/models/post.js
@@ -334,6 +334,24 @@ class Post extends events.EventTarget {
});
}
+ ban() {
+ return api
+ .post(uri.formatApiLink("post-ban"), {
+ post_id: this.id,
+ version: this._version
+ })
+ .then((response) => {
+ this.dispatchEvent(
+ new CustomEvent("ban", {
+ detail: {
+ post: this
+ }
+ })
+ );
+ return Promise.resolve();
+ })
+ }
+
delete() {
return api
.delete(uri.formatApiLink("post", this.id), {
diff --git a/client/js/models/top_navigation.js b/client/js/models/top_navigation.js
index a469a034..3a680010 100644
--- a/client/js/models/top_navigation.js
+++ b/client/js/models/top_navigation.js
@@ -90,6 +90,7 @@ function _makeTopNavigation() {
ret.add("login", new TopNavigationItem("L", "Log in", "login"));
ret.add("logout", new TopNavigationItem("O", "Logout", "logout"));
ret.add("help", new TopNavigationItem("E", "Help", "help"));
+ ret.add("banned-posts", new TopNavigationItem("B", "Banned posts", "banned-posts"));
ret.add(
"settings",
new TopNavigationItem(null, "", "settings")
diff --git a/client/js/views/banned_posts_view.js b/client/js/views/banned_posts_view.js
new file mode 100644
index 00000000..0a29d955
--- /dev/null
+++ b/client/js/views/banned_posts_view.js
@@ -0,0 +1,103 @@
+"use strict";
+
+const events = require("../events.js");
+const views = require("../util/views.js");
+const BannedPost = require("../models/banned_post.js");
+
+const template = views.getTemplate("banned-post-list");
+const rowTemplate = views.getTemplate("banned-post-entry");
+
+class BannedPostsView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+ this._ctx = ctx;
+ this._hostNode = document.getElementById("content-holder");
+
+ views.replaceContent(this._hostNode, template(ctx));
+ views.syncScrollPosition();
+ views.decorateValidator(this._formNode);
+
+ const bannedPostsToAdd = Array.from(ctx.bannedPosts);
+ for (let bannedPost of bannedPostsToAdd) {
+ this._addBannedPostRowNode(bannedPost);
+ }
+
+ ctx.bannedPosts.addEventListener("remove", (e) =>
+ this._evtBannedPostDeleted(e)
+ );
+
+ this._formNode.addEventListener("submit", (e) =>
+ this._evtSaveButtonClick(e, ctx)
+ );
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector("form");
+ }
+
+ get _tableBodyNode() {
+ return this._hostNode.querySelector("tbody");
+ }
+
+ _addBannedPostRowNode(bannedPost) {
+ const rowNode = rowTemplate(
+ Object.assign({}, this._ctx, { postBan: bannedPost })
+ );
+
+ const removeLinkNode = rowNode.querySelector(".remove a");
+ if (removeLinkNode) {
+ removeLinkNode.addEventListener("click", (e) =>
+ this._evtDeleteButtonClick(e, rowNode)
+ );
+ }
+
+ this._tableBodyNode.appendChild(rowNode);
+
+ rowNode._bannedPost = bannedPost;
+ bannedPost._rowNode = rowNode;
+ }
+
+ _removeBannedPostRowNode(bannedPost) {
+ const rowNode = bannedPost._rowNode;
+ rowNode.parentNode.removeChild(rowNode);
+ }
+
+ _evtBannedPostDeleted(e) {
+ this._removeBannedPostRowNode(e.detail.bannedPost);
+ }
+
+ _evtDeleteButtonClick(e, rowNode, link) {
+ e.preventDefault();
+ if (e.target.classList.contains("inactive")) {
+ return;
+ }
+ this._ctx.bannedPosts.remove(rowNode._bannedPost);
+ }
+
+ _evtSaveButtonClick(e, ctx) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent("submit"));
+ }
+}
+
+module.exports = BannedPostsView;
diff --git a/doc/API.md b/doc/API.md
index 00ee75a9..3272257d 100644
--- a/doc/API.md
+++ b/doc/API.md
@@ -39,6 +39,7 @@
- [Getting post](#getting-post)
- [Getting around post](#getting-around-post)
- [Deleting post](#deleting-post)
+ - [Banning post](#banning-post)
- [Merging posts](#merging-posts)
- [Rating post](#rating-post)
- [Adding post to favorites](#adding-post-to-favorites)
@@ -911,7 +912,7 @@ data.
- **Output**
- A [post resource](#post).
+ A [post resource](#banned-post).
- **Errors**
@@ -1004,6 +1005,81 @@ data.
Deletes existing post. Related posts and tags are kept.
+
+
+## Listing banned posts
+- **Request**
+
+ `GET /post-ban`
+
+- **Output**
+
+ An [unpaged search result](#unpaged-search-result) of [banned posts](#postban).
+
+- **Errors**
+
+ - the post does not exist
+ - privileges are too low
+
+- **Description**
+
+ Retrieves information about an existing post.
+
+## Banning post
+- **Request**
+
+ `POST /post-ban`
+
+- **Input**
+
+ ```json5
+ {
+ "post_id":
+ "version":
+ }
+ ```
+
+- **Output**
+
+ ```json5
+ {}
+ ```
+
+- **Errors**
+
+ - the version is outdated
+ - the post does not exist
+ - privileges are too low
+
+- **Description**
+
+ Deletes existing post, then adds the sha1sum of the post file to a ban list.
+ Users will not be able to upload the file again.
+
+ Related posts and tags are kept.
+
+
+## Undoing post ban
+- **Request**
+
+ `DELETE /post-ban/`
+
+- **Output**
+
+ ```json5
+ {}
+ ```
+
+- **Errors**
+
+ - there is no banned image with that hash
+ - privileges are too low
+
+- **Description**
+
+ Removes a banned image from the ban list. Takes a SHA-1 hash of the image as input.
+
+
## Merging posts
- **Request**
@@ -2582,6 +2658,26 @@ An ordered list of posts, with a description and category.
A [pool resource](#pool) stripped down to `id`, `names`, `category`,
`description` and `postCount` fields.
+
+## Banned post
+**Description**
+
+A record of a post that has been banned.
+
+**Structure**
+
+```json5
+{
+ "checksum": ,
+ "time":
+}
+```
+
+**Field meaning**
+- ``: SHA-1 hash of an image that has been banned
+- ``: time the post was banned
+
+
## Comment
**Description**
diff --git a/server/config.yaml.dist b/server/config.yaml.dist
index 193aac3a..63bc99fa 100644
--- a/server/config.yaml.dist
+++ b/server/config.yaml.dist
@@ -116,6 +116,9 @@ privileges:
'posts:bulk-edit:tags': power
'posts:bulk-edit:safety': power
'posts:bulk-edit:delete': power
+ 'posts:ban:create': moderator
+ 'posts:ban:delete': moderator
+ 'posts:ban:list': moderator
'tags:create': regular
'tags:edit:names': power
diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py
index d9b7ecba..b1ba6b3b 100644
--- a/server/szurubooru/api/__init__.py
+++ b/server/szurubooru/api/__init__.py
@@ -10,3 +10,4 @@ import szurubooru.api.tag_category_api
import szurubooru.api.upload_api
import szurubooru.api.user_api
import szurubooru.api.user_token_api
+import szurubooru.api.ban_api
\ No newline at end of file
diff --git a/server/szurubooru/api/ban_api.py b/server/szurubooru/api/ban_api.py
new file mode 100644
index 00000000..10b0f956
--- /dev/null
+++ b/server/szurubooru/api/ban_api.py
@@ -0,0 +1,58 @@
+from datetime import datetime
+from typing import Dict, List, Optional
+from szurubooru.api import post_api
+from szurubooru.func import posts
+from szurubooru.model.bans import PostBan
+
+from szurubooru import db, errors, model, rest, search
+from szurubooru.func import (
+ auth,
+ bans,
+ serialization,
+ snapshots,
+ versions,
+)
+
+def _get_ban_by_hash(hash: str) -> Optional[PostBan]:
+ try:
+ return bans.get_bans_by_hash(hash)
+ except:
+ return None
+
+
+_search_executor = search.Executor(search.configs.BanSearchConfig())
+
+
+def _serialize(ctx: rest.Context, ban: model.PostBan) -> rest.Response:
+ return bans.serialize_ban(
+ ban, options=serialization.get_serialization_options(ctx)
+ )
+
+
+@rest.routes.delete("/post-ban/(?P[^/]+)/?")
+def unban_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
+ auth.verify_privilege(ctx.user, "posts:ban:delete")
+ ban = _get_ban_by_hash(params["image_hash"])
+ bans.delete(ban)
+ ctx.session.commit()
+ return {}
+
+
+@rest.routes.post("/post-ban/?")
+def ban_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
+ auth.verify_privilege(ctx.user, "posts:ban:create")
+ # post = post_api._get_post(params)
+ post = posts.get_post_by_id(ctx.get_param_as_int("post_id"))
+ versions.verify_version(post, ctx)
+ posts.ban(bans.create_ban(post))
+ snapshots.delete(post, ctx.user)
+ posts.delete(post)
+ ctx.session.commit()
+ return {}
+
+@rest.routes.get("/post-ban/?")
+def get_bans(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
+ auth.verify_privilege(ctx.user, "posts:ban:list")
+ return _search_executor.execute_and_serialize(
+ ctx, lambda ban: _serialize(ctx, ban)
+ )
diff --git a/server/szurubooru/func/bans.py b/server/szurubooru/func/bans.py
new file mode 100644
index 00000000..694dda62
--- /dev/null
+++ b/server/szurubooru/func/bans.py
@@ -0,0 +1,68 @@
+from datetime import datetime
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+from szurubooru import db, errors, model, rest
+from szurubooru.func import (
+ serialization,
+)
+
+class PostBannedError(errors.ValidationError):
+ def __init__(self, message: str = "This file was banned", extra_fields: Dict[str, str] = None) -> None:
+ super().__init__(message, extra_fields)
+
+
+class HashNotBannedError(errors.ValidationError):
+ pass
+
+
+class BanSerializer(serialization.BaseSerializer):
+ def __init__(self, ban: model.PostBan) -> None:
+ self.ban = ban
+
+ def _serializers(self) -> Dict[str, Callable[[], Any]]:
+ return {
+ "checksum": self.serialize_checksum,
+ "time": self.serialize_time
+ }
+
+ def serialize_checksum(self) -> Any:
+ return self.ban.checksum
+
+ def serialize_time(self) -> Any:
+ return self.ban.time
+
+
+def create_ban(post: model.Post) -> model.PostBan:
+ ban = model.PostBan()
+ ban.checksum = post.checksum
+ ban.time = datetime.utcnow()
+
+ db.session.add(ban)
+ return ban
+
+
+def try_get_ban_by_checksum(checksum: str) -> Optional[model.PostBan]:
+ return (
+ db.session.query(model.PostBan)
+ .filter(model.PostBan.checksum == checksum)
+ .one_or_none()
+ )
+
+
+def get_bans_by_hash(hash: str) -> model.PostBan:
+ ban = try_get_ban_by_checksum(hash)
+ if ban is None:
+ raise HashNotBannedError("Hash %s is not banned" % hash)
+ return ban
+
+
+def delete(ban: model.PostBan) -> None:
+ db.session.delete(ban)
+
+
+def serialize_ban(
+ ban: model.PostBan, options: List[str] = []
+) -> Optional[rest.Response]:
+ if not ban:
+ return None
+ return BanSerializer(ban).serialize(options)
diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py
index be2259cf..0348532b 100644
--- a/server/szurubooru/func/posts.py
+++ b/server/szurubooru/func/posts.py
@@ -4,6 +4,7 @@ from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple
import sqlalchemy as sa
+from szurubooru.func.bans import PostBannedError
from szurubooru import config, db, errors, model, rest
from szurubooru.func import (
@@ -50,6 +51,7 @@ class PostAlreadyUploadedError(errors.ValidationError):
)
+
class InvalidPostIdError(errors.ValidationError):
pass
@@ -634,6 +636,7 @@ def update_post_content(post: model.Post, content: Optional[bytes]) -> None:
.filter(model.Post.post_id != post.post_id)
.one_or_none()
)
+
if (
other_post
and other_post.post_id
@@ -641,6 +644,15 @@ def update_post_content(post: model.Post, content: Optional[bytes]) -> None:
):
raise PostAlreadyUploadedError(other_post)
+
+ post_ban = (db.session.query(model.PostBan)
+ .filter(model.PostBan.checksum == post.checksum)
+ .one_or_none()
+ )
+ if (post_ban):
+ raise PostBannedError()
+
+
if update_signature:
purge_post_signature(post)
post.signature = generate_post_signature(post, content)
@@ -806,6 +818,11 @@ def delete(post: model.Post) -> None:
db.session.delete(post)
+def ban(ban: model.PostBan) -> None:
+ assert ban
+ db.session.add(ban)
+
+
def merge_posts(
source_post: model.Post, target_post: model.Post, replace_content: bool
) -> None:
diff --git a/server/szurubooru/migrations/versions/cc2956cb8ee7_create_ban_table.py b/server/szurubooru/migrations/versions/cc2956cb8ee7_create_ban_table.py
new file mode 100644
index 00000000..f4f40e5f
--- /dev/null
+++ b/server/szurubooru/migrations/versions/cc2956cb8ee7_create_ban_table.py
@@ -0,0 +1,29 @@
+'''
+create ban table
+
+Revision ID: cc2956cb8ee7
+Created at: 2023-05-12 02:04:22.592006
+'''
+
+import sqlalchemy as sa
+from alembic import op
+
+
+
+revision = 'cc2956cb8ee7'
+down_revision = 'adcd63ff76a2'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ op.create_table(
+ "post_ban",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("checksum", sa.Unicode(64), nullable=False),
+ sa.Column("time", sa.DateTime, nullable=False),
+ sa.PrimaryKeyConstraint("id")
+ )
+ op.create_unique_constraint("uq_ban_checksum", "post_ban", ["checksum"])
+
+def downgrade():
+ op.drop_table("post_ban")
diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py
index 21a178ef..70d20452 100644
--- a/server/szurubooru/model/__init__.py
+++ b/server/szurubooru/model/__init__.py
@@ -1,5 +1,6 @@
import szurubooru.model.util
from szurubooru.model.base import Base
+from szurubooru.model.bans import PostBan
from szurubooru.model.comment import Comment, CommentScore
from szurubooru.model.pool import Pool, PoolName, PoolPost
from szurubooru.model.pool_category import PoolCategory
diff --git a/server/szurubooru/model/bans.py b/server/szurubooru/model/bans.py
new file mode 100644
index 00000000..8abeb7f7
--- /dev/null
+++ b/server/szurubooru/model/bans.py
@@ -0,0 +1,12 @@
+from typing import List
+
+import sqlalchemy as sa
+
+from szurubooru.model.base import Base
+
+class PostBan(Base):
+ __tablename__ = "post_ban"
+
+ ban_id = sa.Column("id", sa.Integer, primary_key=True)
+ checksum = sa.Column("checksum", sa.Unicode(64), nullable=False)
+ time = sa.Column("time", sa.DateTime, nullable=False)
diff --git a/server/szurubooru/search/configs/__init__.py b/server/szurubooru/search/configs/__init__.py
index c7218131..dec5ed7f 100644
--- a/server/szurubooru/search/configs/__init__.py
+++ b/server/szurubooru/search/configs/__init__.py
@@ -4,3 +4,4 @@ from .post_search_config import PostSearchConfig
from .snapshot_search_config import SnapshotSearchConfig
from .tag_search_config import TagSearchConfig
from .user_search_config import UserSearchConfig
+from .ban_search_config import BanSearchConfig
\ No newline at end of file
diff --git a/server/szurubooru/search/configs/ban_search_config.py b/server/szurubooru/search/configs/ban_search_config.py
new file mode 100644
index 00000000..ce8bba7d
--- /dev/null
+++ b/server/szurubooru/search/configs/ban_search_config.py
@@ -0,0 +1,67 @@
+from typing import Dict, Tuple
+
+import sqlalchemy as sa
+
+from szurubooru import db, model
+from szurubooru.func import util
+from szurubooru.search.configs import util as search_util
+from szurubooru.search.configs.base_search_config import (
+ BaseSearchConfig,
+ Filter,
+)
+from szurubooru.search.typing import SaColumn, SaQuery
+
+
+class BanSearchConfig(BaseSearchConfig):
+ def create_filter_query(self, _disable_eager_loads: bool) -> SaQuery:
+ return db.session.query(model.PostBan)
+
+ def create_count_query(self, _disable_eager_loads: bool) -> SaQuery:
+ return db.session.query(model.PostBan)
+
+ def create_around_query(self) -> SaQuery:
+ raise NotImplementedError()
+
+ def finalize_query(self, query: SaQuery) -> SaQuery:
+ return query.order_by(model.PostBan.time.asc())
+
+ @property
+ def anonymous_filter(self) -> Filter:
+ return search_util.create_subquery_filter(
+ model.PostBan.checksum,
+ model.PostBan.time,
+ search_util.create_str_filter,
+ )
+
+ @property
+ def named_filters(self) -> Dict[str, Filter]:
+ return util.unalias_dict(
+ [
+ (
+ ["time"],
+ search_util.create_date_filter(
+ model.PostBan.time,
+ ),
+ ),
+ (
+ ["checksum"],
+ search_util.create_subquery_filter(
+ model.PostBan.checksum,
+ search_util.create_str_filter,
+ ),
+ )
+ ]
+ )
+
+ @property
+ def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]:
+ return util.unalias_dict(
+ [
+ (
+ ["random"],
+ (sa.sql.expression.func.random(), self.SORT_NONE),
+ ),
+ (["checksum"], (model.PostBan.checksum, self.SORT_ASC)),
+ (["time"], (model.PostBan.time, self.SORT_ASC))
+ ]
+ )