added new nodes
This commit is contained in:
+2
-1
@@ -1,2 +1,3 @@
|
||||
__pycache__
|
||||
/pycache/*
|
||||
/pycache/*
|
||||
loaders/imgbb_api_key.json
|
||||
|
||||
@@ -42,6 +42,29 @@ Here is an example of the images Side by Side instead of split.
|
||||
|
||||

|
||||
|
||||
### Image to imgBB
|
||||
|
||||

|
||||
|
||||
These nodes enable uploading and downloading to / from the [imgBB](https://www.imgbb.com/) image sharing service. Also included are nodes for downloading images from imgBB and an image URL node that preserves the uploaded image URL in the workflow for easy sharing of originals with others.
|
||||
Perfvect for sharing workflows while making original images available for others.
|
||||
|
||||
#### Setup
|
||||
|
||||
In order to use these nodes, you must have an account with imgBB service. Once you have your account, navigate to (https://api.imgbb.com/) and generate an API key. You will need to configure this key in the [imgbb_api_key.json] file in the nodes folder (./loaders/) folder.
|
||||
There is a sample file [imgbb_api_key_example.json] you can copy and rename to [imgbb_api_key.json], edit it and enter your API key replacing the text "YOUR_API_KEY_HERE" with your key. See example below.
|
||||
|
||||
> {
|
||||
> "api_key": "8a54a1b12353d43105d62fxadr3286a3323x"
|
||||
> }
|
||||
|
||||
### Smart Checkpoint Loader
|
||||
|
||||

|
||||
|
||||
This is a one-for-one replacement of the core Load Checkpoint node with one key difference: It flattens your directory structure regardless of how complex and makes all checkpoints appear as if on one folder. This is ideal for sharing workflows where the original author may have
|
||||
a different directory structure than other users. Makes organizing checkpoints and sharing workflows easier.
|
||||
|
||||
## Examples
|
||||
|
||||
### Sample Workflow
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 463 KiB |
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
@app_author: YFG
|
||||
@app_title: YFG Image to imgBB
|
||||
@app_nickname: 🐯 YFG Image to imgBB
|
||||
@app_description: This node loads an image, optionally uploads it to imgbb, and returns the image along with the image URL and API results.
|
||||
"""
|
||||
import io
|
||||
import torch
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps, ImageSequence
|
||||
from io import BytesIO
|
||||
|
||||
# Ensure the comfy directory is in the system path for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "comfy"))
|
||||
|
||||
from comfy.cli_args import args
|
||||
import folder_paths
|
||||
import node_helpers
|
||||
|
||||
class Image2ImgBB:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
|
||||
return {
|
||||
"required": {
|
||||
"image": (["None"] + sorted(files), {"image_upload": True}),
|
||||
"upload_to_imgbb": ("BOOLEAN", {"default": False, "description": "Upload to imgBB?"})
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING")
|
||||
RETURN_NAMES = ("IMAGE", "MASK", "Image URL", "API Response")
|
||||
FUNCTION = "process_image"
|
||||
CATEGORY = "🐯 YFG/Loaders"
|
||||
FRIENDLY_NAME = "Image to imgBB"
|
||||
|
||||
def process_image(self, image, upload_to_imgbb, **kwargs):
|
||||
graph_metadata = kwargs.get('graph_metadata', {})
|
||||
img_url = graph_metadata.get('image_url', '')
|
||||
|
||||
output_image, output_mask, api_results = None, None, "No API call made"
|
||||
image_url = img_url
|
||||
|
||||
# Attempt to load the image
|
||||
if image != "None":
|
||||
try:
|
||||
image_path = folder_paths.get_annotated_filepath(image)
|
||||
output_image, output_mask = self.load_local_image(image_path)
|
||||
api_results = f"Loaded local image: {image}"
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
print(f"Local image loading failed: {e}")
|
||||
|
||||
# If local image loading failed, try to use the URL
|
||||
if output_image is None and img_url:
|
||||
if self.is_valid_url(img_url):
|
||||
try:
|
||||
print(f"Loading image from URL: {img_url}")
|
||||
output_image, output_mask = self.load_image_from_url(img_url)
|
||||
api_results = f"Using image from URL: {img_url}"
|
||||
except Exception as e:
|
||||
print(f"Failed to load image from URL: {e}")
|
||||
api_results = f"Failed to load image from URL: {img_url}"
|
||||
else:
|
||||
print(f"Invalid URL: {img_url}")
|
||||
api_results = f"Invalid URL: {img_url}"
|
||||
|
||||
# Upload the image to imgBB if required
|
||||
if upload_to_imgbb and output_image is not None:
|
||||
try:
|
||||
api_results = self.upload_to_imgbb_binary(image_path)
|
||||
api_response_json = json.loads(api_results)
|
||||
img_url = api_response_json.get('data', {}).get('url', '')
|
||||
if img_url:
|
||||
graph_metadata['image_url'] = img_url
|
||||
kwargs['graph_metadata'] = graph_metadata
|
||||
image_url = img_url
|
||||
print(f"Image uploaded to imgBB, URL saved: {img_url}")
|
||||
except Exception as e:
|
||||
print(f"Failed to upload image to imgBB: {e}")
|
||||
api_results = f"Failed to upload image to imgBB: {e}"
|
||||
|
||||
print("API Response:", api_results)
|
||||
return output_image, output_mask, image_url, api_results
|
||||
|
||||
def load_local_image(self, image_path):
|
||||
if not os.path.exists(image_path):
|
||||
raise FileNotFoundError(f"Local image file not found: {image_path}")
|
||||
|
||||
img = node_helpers.pillow(Image.open, image_path)
|
||||
output_images, output_masks = [], []
|
||||
w, h = None, None
|
||||
|
||||
for i in ImageSequence.Iterator(img):
|
||||
i = node_helpers.pillow(ImageOps.exif_transpose, i)
|
||||
|
||||
if i.mode == 'I':
|
||||
i = i.point(lambda i: i * (1 / 255))
|
||||
image = i.convert("RGB")
|
||||
|
||||
if len(output_images) == 0:
|
||||
w = image.size[0]
|
||||
h = image.size[1]
|
||||
|
||||
if image.size[0] != w or image.size[1] != h:
|
||||
raise ValueError("Image size mismatch")
|
||||
|
||||
image = np.array(image).astype(np.float32) / 255.0
|
||||
image = torch.from_numpy(image)[None,]
|
||||
if 'A' in i.getbands():
|
||||
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
|
||||
mask = 1. - torch.from_numpy(mask)
|
||||
else:
|
||||
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
|
||||
output_images.append(image)
|
||||
output_masks.append(mask.unsqueeze(0))
|
||||
|
||||
if len(output_images) > 1:
|
||||
output_image = torch.cat(output_images, dim=0)
|
||||
output_mask = torch.cat(output_masks, dim=0)
|
||||
else:
|
||||
output_image = output_images[0]
|
||||
output_mask = output_masks[0]
|
||||
|
||||
return output_image, output_mask
|
||||
|
||||
def load_image_from_url(self, url):
|
||||
response = requests.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
img = node_helpers.pillow(Image.open(BytesIO(response.content)))
|
||||
|
||||
output_images, output_masks = [], []
|
||||
w, h = None, None
|
||||
|
||||
for i in ImageSequence.Iterator(img):
|
||||
i = node_helpers.pillow(ImageOps.exif_transpose, i)
|
||||
|
||||
if i.mode == 'I':
|
||||
i = i.point(lambda i: i * (1 / 255))
|
||||
image = i.convert("RGB")
|
||||
|
||||
if len(output_images) == 0:
|
||||
w = image.size[0]
|
||||
h = image.size[1]
|
||||
|
||||
if image.size[0] != w or image.size[1] != h:
|
||||
raise ValueError("Image size mismatch")
|
||||
|
||||
image = np.array(image).astype(np.float32) / 255.0
|
||||
image = torch.from_numpy(image)[None,]
|
||||
if 'A' in i.getbands():
|
||||
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
|
||||
mask = 1. - torch.from_numpy(mask)
|
||||
else:
|
||||
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
|
||||
output_images.append(image)
|
||||
output_masks.append(mask.unsqueeze(0))
|
||||
|
||||
if len(output_images) > 1:
|
||||
output_image = torch.cat(output_images, dim=0)
|
||||
output_mask = torch.cat(output_masks, dim=0)
|
||||
else:
|
||||
output_image = output_images[0]
|
||||
output_mask = output_masks[0]
|
||||
|
||||
return output_image, output_mask
|
||||
|
||||
def upload_to_imgbb_binary(self, image_path):
|
||||
api_key_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "imgbb_api_key.json")
|
||||
|
||||
try:
|
||||
with open(api_key_path, "r") as key_file:
|
||||
api_key = json.load(key_file)["api_key"]
|
||||
except FileNotFoundError as fnf_error:
|
||||
print(f"File not found: {fnf_error}")
|
||||
return f"Error: {fnf_error}"
|
||||
except json.JSONDecodeError as json_error:
|
||||
print(f"Error decoding JSON: {json_error}")
|
||||
return f"Error: {json_error}"
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
with open(image_path, "rb") as image_file:
|
||||
image_data = image_file.read()
|
||||
|
||||
url = "https://api.imgbb.com/1/upload"
|
||||
files = {"image": (os.path.basename(image_path), image_data)}
|
||||
payload = {
|
||||
"key": api_key,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, files=files, data=payload)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
print("API Response:", json.dumps(result, indent=2))
|
||||
return json.dumps(result, indent=2)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error uploading image: {e}")
|
||||
return f"Error uploading image: {e}"
|
||||
|
||||
def is_valid_url(self, url):
|
||||
return url.startswith('http://') or url.startswith('https://')
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, image):
|
||||
image_path = folder_paths.get_annotated_filepath(image)
|
||||
m = hashlib.sha256()
|
||||
with open(image_path, 'rb') as f:
|
||||
m.update(f.read())
|
||||
return m.digest().hex()
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, image):
|
||||
if not folder_paths.exists_annotated_filepath(image):
|
||||
return f"Invalid image file: {image}"
|
||||
return True
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
@app_author: YFG
|
||||
@app_title: YFG Image Loader from imgBB
|
||||
@app_nickname: 🐯 YFG imgBB Loader
|
||||
@app_description: This node loads an image from a provided URL and outputs the image and mask.
|
||||
"""
|
||||
import io
|
||||
import torch
|
||||
import requests
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps, ImageSequence
|
||||
from io import BytesIO
|
||||
import os
|
||||
import folder_paths
|
||||
import comfy.utils
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
from comfy.cli_args import args
|
||||
|
||||
import sys
|
||||
|
||||
# Ensure the comfy directory is in the system path for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "comfy"))
|
||||
|
||||
def tensor2pil(image: torch.Tensor, mode=None):
|
||||
return numpy2pil(image.cpu().numpy().squeeze(), mode=mode)
|
||||
|
||||
def pil2numpy(image: Image.Image):
|
||||
return np.array(image).astype(np.float32) / 255.0
|
||||
|
||||
def numpy2pil(image: np.ndarray, mode=None):
|
||||
return Image.fromarray(np.clip(255.0 * image, 0, 255).astype(np.uint8), mode)
|
||||
|
||||
def prepare_image_for_preview(image: Image.Image, output_dir: str, prefix=None):
|
||||
if prefix is None:
|
||||
prefix = "preview_" + "".join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
|
||||
|
||||
# Save image to temp folder
|
||||
(
|
||||
outdir,
|
||||
filename,
|
||||
counter,
|
||||
subfolder,
|
||||
_,
|
||||
) = folder_paths.get_save_image_path(prefix, output_dir, image.width, image.height)
|
||||
file = f"{filename}_{counter:05}_.png"
|
||||
image.save(os.path.join(outdir, file), format="PNG", compress_level=4)
|
||||
|
||||
return {
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"type": "temp",
|
||||
}
|
||||
|
||||
def load_images_from_url(urls: List[str], keep_alpha_channel=False):
|
||||
images = []
|
||||
masks = []
|
||||
|
||||
for url in urls:
|
||||
if url.startswith("data:image/"):
|
||||
i = Image.open(io.BytesIO(base64.b64decode(url.split(",")[1])))
|
||||
elif url.startswith("file://"):
|
||||
url = url[7:]
|
||||
if not os.path.isfile(url):
|
||||
raise Exception(f"File {url} does not exist")
|
||||
|
||||
i = Image.open(url)
|
||||
elif url.startswith("http://") or url.startswith("https://"):
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code != 200:
|
||||
raise Exception(response.text)
|
||||
|
||||
i = Image.open(io.BytesIO(response.content))
|
||||
elif url.startswith("/view?"):
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
qs = parse_qs(url[6:])
|
||||
filename = qs.get("name", qs.get("filename", None))
|
||||
if filename is None:
|
||||
raise Exception(f"Invalid url: {url}")
|
||||
|
||||
filename = filename[0]
|
||||
subfolder = qs.get("subfolder", None)
|
||||
if subfolder is not None:
|
||||
filename = os.path.join(subfolder[0], filename)
|
||||
|
||||
dirtype = qs.get("type", ["input"])
|
||||
if dirtype[0] == "input":
|
||||
url = os.path.join(folder_paths.get_input_directory(), filename)
|
||||
elif dirtype[0] == "output":
|
||||
url = os.path.join(folder_paths.get_output_directory(), filename)
|
||||
elif dirtype[0] == "temp":
|
||||
url = os.path.join(folder_paths.get_temp_directory(), filename)
|
||||
else:
|
||||
raise Exception(f"Invalid url: {url}")
|
||||
|
||||
i = Image.open(url)
|
||||
elif url == "":
|
||||
continue
|
||||
else:
|
||||
url = folder_paths.get_annotated_filepath(url)
|
||||
if not os.path.isfile(url):
|
||||
raise Exception(f"Invalid url: {url}")
|
||||
|
||||
i = Image.open(url)
|
||||
|
||||
i = ImageOps.exif_transpose(i)
|
||||
has_alpha = "A" in i.getbands()
|
||||
mask = None
|
||||
|
||||
if "RGB" not in i.mode:
|
||||
i = i.convert("RGBA") if has_alpha else i.convert("RGB")
|
||||
|
||||
if has_alpha:
|
||||
mask = i.getchannel("A")
|
||||
|
||||
# Recreate image to fix weird RGB image
|
||||
alpha = i.split()[-1]
|
||||
image = Image.new("RGB", i.size, (0, 0, 0))
|
||||
image.paste(i, mask=alpha)
|
||||
image.putalpha(alpha)
|
||||
|
||||
if not keep_alpha_channel:
|
||||
image = image.convert("RGB")
|
||||
else:
|
||||
image = i
|
||||
|
||||
images.append(image)
|
||||
masks.append(mask)
|
||||
|
||||
return (images, masks)
|
||||
|
||||
class ImgbbLoader:
|
||||
def __init__(self) -> None:
|
||||
self.output_dir = folder_paths.get_temp_directory()
|
||||
self.filename_prefix = "TempImageFromUrl"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"url": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False}),
|
||||
},
|
||||
"optional": {
|
||||
"keep_alpha_channel": (
|
||||
"BOOLEAN",
|
||||
{"default": False, "label_on": "enabled", "label_off": "disabled"},
|
||||
),
|
||||
"output_mode": (
|
||||
"BOOLEAN",
|
||||
{"default": False, "label_on": "list", "label_off": "batch"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "BOOLEAN")
|
||||
OUTPUT_IS_LIST = (True, True, False)
|
||||
RETURN_NAMES = ("images", "masks", "has_image")
|
||||
CATEGORY = "🐯 YFG/Loaders"
|
||||
FUNCTION = "load_image_from_url"
|
||||
|
||||
def load_image_from_url(self, url="", keep_alpha_channel=False, output_mode=False, **kwargs):
|
||||
if not url:
|
||||
raise ValueError("No URL provided for image loading")
|
||||
|
||||
urls = url.strip().split("\n")
|
||||
images, masks = load_images_from_url(urls, keep_alpha_channel)
|
||||
if len(images) == 0:
|
||||
image = torch.zeros((1, 64, 64, 3), dtype=torch.float32, device="cpu")
|
||||
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
|
||||
return ([image], [mask], False)
|
||||
|
||||
previews = []
|
||||
np_images = []
|
||||
np_masks = []
|
||||
|
||||
for image, mask in zip(images, masks):
|
||||
# Save image to temp folder
|
||||
preview = prepare_image_for_preview(image, self.output_dir, self.filename_prefix)
|
||||
image_tensor = self.pil2tensor(image)
|
||||
|
||||
if mask:
|
||||
mask_tensor = np.array(mask).astype(np.float32) / 255.0
|
||||
mask_tensor = 1.0 - torch.from_numpy(mask_tensor)
|
||||
else:
|
||||
mask_tensor = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
|
||||
|
||||
previews.append(preview)
|
||||
np_images.append(image_tensor)
|
||||
np_masks.append(mask_tensor.unsqueeze(0))
|
||||
|
||||
if output_mode:
|
||||
result = (np_images, np_masks, True)
|
||||
else:
|
||||
has_size_mismatch = False
|
||||
if len(np_images) > 1:
|
||||
for image in np_images[1:]:
|
||||
if image.shape[1] != np_images[0].shape[1] or image.shape[2] != np_images[0].shape[2]:
|
||||
has_size_mismatch = True
|
||||
break
|
||||
|
||||
if has_size_mismatch:
|
||||
raise Exception("To output as batch, images must have the same size. Use list output mode instead.")
|
||||
|
||||
result = ([torch.cat(np_images)], [torch.cat(np_masks)], True)
|
||||
|
||||
return {"ui": {"images": previews}, "result": result}
|
||||
|
||||
def pil2tensor(self, image):
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0)[None,]
|
||||
|
||||
def is_valid_url(self, url):
|
||||
return url.startswith('http://') or url.startswith('https://')
|
||||
|
||||
class storeURL:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
}
|
||||
|
||||
INPUT_IS_LIST = True
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "getURL"
|
||||
OUTPUT_NODE = True
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
|
||||
CATEGORY = "🐯 YFG/Loaders"
|
||||
|
||||
def getURL(self, text, unique_id=None, extra_pnginfo=None):
|
||||
if unique_id is not None and extra_pnginfo is not None:
|
||||
if not isinstance(extra_pnginfo, list):
|
||||
print("Error: extra_pnginfo is not a list")
|
||||
elif (
|
||||
not isinstance(extra_pnginfo[0], dict)
|
||||
or "workflow" not in extra_pnginfo[0]
|
||||
):
|
||||
print("Error: extra_pnginfo[0] is not a dict or missing 'workflow' key")
|
||||
else:
|
||||
workflow = extra_pnginfo[0]["workflow"]
|
||||
node = next(
|
||||
(x for x in workflow["nodes"] if str(x["id"]) == str(unique_id[0])),
|
||||
None,
|
||||
)
|
||||
if node:
|
||||
node["widgets_values"] = [text]
|
||||
|
||||
return {"ui": {"text": text}, "result": (text,)}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"api_key": "YOUR_API_KEY_HERE"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { ComfyWidgets } from "../../../scripts/widgets.js";
|
||||
|
||||
// Displays input text on a node
|
||||
app.registerExtension({
|
||||
name: "YFG.storeURL",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "storeURL_node") {
|
||||
function populate(text) {
|
||||
if (this.widgets) {
|
||||
for (let i = 1; i < this.widgets.length; i++) {
|
||||
this.widgets[i].onRemove?.();
|
||||
}
|
||||
this.widgets.length = 1;
|
||||
}
|
||||
|
||||
const v = [...text];
|
||||
if (!v[0]) {
|
||||
v.shift();
|
||||
}
|
||||
for (const list of v) {
|
||||
const w = ComfyWidgets["STRING"](this, "text", ["STRING", { multiline: true }], app).widget;
|
||||
w.inputEl.readOnly = true;
|
||||
w.inputEl.style.opacity = 0.6;
|
||||
w.value = list;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const sz = this.computeSize();
|
||||
if (sz[0] < this.size[0]) {
|
||||
sz[0] = this.size[0];
|
||||
}
|
||||
if (sz[1] < this.size[1]) {
|
||||
sz[1] = this.size[1];
|
||||
}
|
||||
this.onResize?.(sz);
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
});
|
||||
}
|
||||
|
||||
// When the node is executed we will be sent the input text, display this in the widget
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
populate.call(this, message.text);
|
||||
};
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
onConfigure?.apply(this, arguments);
|
||||
if (this.widgets_values?.length) {
|
||||
populate.call(this, this.widgets_values);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user