Nextcloud login flow v2, Network code rewrite
This commit is contained in:
78
Nextcloud Cookbook iOS Client/Network/APIInterface.swift
Normal file
78
Nextcloud Cookbook iOS Client/Network/APIInterface.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// APIInterface.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 20.09.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class APIInterface {
|
||||
var userSettings: UserSettings
|
||||
|
||||
var apiPath: String
|
||||
var authString: String
|
||||
let apiVersion = "1"
|
||||
|
||||
init(userSettings: UserSettings) {
|
||||
print("Initializing NetworkController.")
|
||||
self.userSettings = userSettings
|
||||
|
||||
self.apiPath = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/"
|
||||
|
||||
let loginString = "\(userSettings.username):\(userSettings.token)"
|
||||
let loginData = loginString.data(using: String.Encoding.utf8)!
|
||||
self.authString = loginData.base64EncodedString()
|
||||
}
|
||||
|
||||
func imageDataFromServer(recipeId: Int, thumb: Bool) async -> Data? {
|
||||
do {
|
||||
let request = RequestWrapper.imageRequest(path: .IMAGE(recipeId: recipeId, thumb: thumb))
|
||||
let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest(
|
||||
request,
|
||||
hostPath: apiPath,
|
||||
authString: authString
|
||||
)
|
||||
guard let data = data else {
|
||||
print("Error receiving or decoding data.")
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
} catch {
|
||||
print("Could not load image from server.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension APIInterface {
|
||||
func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
|
||||
do {
|
||||
let (data, error) = try await NetworkHandler.sendHTTPRequest(
|
||||
request,
|
||||
hostPath: apiPath,
|
||||
authString: authString
|
||||
)
|
||||
if let data = data {
|
||||
return (JSONDecoder.safeDecode(data), error)
|
||||
}
|
||||
return (nil, error)
|
||||
} catch {
|
||||
print("An unknown network error occured.")
|
||||
}
|
||||
return (nil, NetworkError.unknownError)
|
||||
}
|
||||
|
||||
func sendRequest(_ request: RequestWrapper) async -> Error? {
|
||||
do {
|
||||
return try await NetworkHandler.sendHTTPRequest(
|
||||
request,
|
||||
hostPath: apiPath,
|
||||
authString: authString
|
||||
).1
|
||||
} catch {
|
||||
print("An unknown network error occured.")
|
||||
}
|
||||
return NetworkError.unknownError
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,15 @@ public enum NotImplementedError: Error, CustomStringConvertible {
|
||||
return "Function not implemented."
|
||||
}
|
||||
}
|
||||
|
||||
public enum NetworkError: String, Error {
|
||||
case missingUrl = "Missing URL."
|
||||
case parametersNil = "Parameters are nil."
|
||||
case encodingFailed = "Parameter encoding failed."
|
||||
case redirectionError = "Redirection error"
|
||||
case clientError = "Client error"
|
||||
case serverError = "Server error"
|
||||
case invalidRequest = "Invalid request"
|
||||
case unknownError = "Unknown error"
|
||||
case dataError = "Invalid data error."
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
//
|
||||
// Extensions.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 14.09.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Formatter {
|
||||
static let positional: DateComponentsFormatter = {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .positional
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
func formatDate(duration: String) -> String {
|
||||
var duration = duration
|
||||
if duration.hasPrefix("PT") { duration.removeFirst(2) }
|
||||
let hour, minute, second: Double
|
||||
if let index = duration.firstIndex(of: "H") {
|
||||
hour = Double(duration[..<index]) ?? 0
|
||||
duration.removeSubrange(...index)
|
||||
} else { hour = 0 }
|
||||
if let index = duration.firstIndex(of: "M") {
|
||||
minute = Double(duration[..<index]) ?? 0
|
||||
duration.removeSubrange(...index)
|
||||
} else { minute = 0 }
|
||||
if let index = duration.firstIndex(of: "S") {
|
||||
second = Double(duration[..<index]) ?? 0
|
||||
} else { second = 0 }
|
||||
return Formatter.positional.string(from: hour * 3600 + minute * 60 + second) ?? "0:00"
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
//
|
||||
// NetworkController.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 13.09.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum NetworkError: String, Error {
|
||||
case missingUrl = "Missing URL."
|
||||
case parametersNil = "Parameters are nil."
|
||||
case encodingFailed = "Parameter encoding failed."
|
||||
case redirectionError = "Redirection error"
|
||||
case clientError = "Client error"
|
||||
case serverError = "Server error"
|
||||
case invalidRequest = "Invalid request"
|
||||
case unknownError = "Unknown error"
|
||||
case dataError = "Invalid data error."
|
||||
}
|
||||
|
||||
class NetworkController {
|
||||
var userSettings: UserSettings
|
||||
var authString: String
|
||||
var cookBookUrlString: String
|
||||
|
||||
let apiVersion = "1"
|
||||
|
||||
init() {
|
||||
print("Initializing NetworkController.")
|
||||
self.userSettings = UserSettings()
|
||||
self.cookBookUrlString = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/"
|
||||
|
||||
let loginString = "\(userSettings.username):\(userSettings.token)"
|
||||
let loginData = loginString.data(using: String.Encoding.utf8)!
|
||||
self.authString = loginData.base64EncodedString()
|
||||
}
|
||||
|
||||
func fetchData(path: String) async throws -> Data? {
|
||||
|
||||
let url = URL(string: "\(cookBookUrlString)/\(path)")!
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(
|
||||
"true",
|
||||
forHTTPHeaderField: "OCS-APIRequest"
|
||||
)
|
||||
request.setValue(
|
||||
"Basic \(authString)",
|
||||
forHTTPHeaderField: "Authorization"
|
||||
)
|
||||
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return data
|
||||
} catch {
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func sendHTTPRequest(_ requestWrapper: RequestWrapper) async throws -> (Data?, NetworkError?) {
|
||||
print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.prepend(cookBookPath: cookBookUrlString))) ...")
|
||||
let urlStringSanitized = requestWrapper.prepend(cookBookPath: cookBookUrlString).addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
let url = URL(string: urlStringSanitized!)!
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue(
|
||||
"true",
|
||||
forHTTPHeaderField: "OCS-APIRequest"
|
||||
)
|
||||
request.setValue(
|
||||
"Basic \(authString)",
|
||||
forHTTPHeaderField: "Authorization"
|
||||
)
|
||||
|
||||
request.setValue(
|
||||
requestWrapper.accept.rawValue,
|
||||
forHTTPHeaderField: "Accept"
|
||||
)
|
||||
|
||||
request.httpMethod = requestWrapper.method.rawValue
|
||||
|
||||
switch requestWrapper.method {
|
||||
case .GET: break
|
||||
case .POST, .PUT:
|
||||
guard let httpBody = requestWrapper.body else { return (nil, nil) }
|
||||
do {
|
||||
print("Encoding request ...")
|
||||
request.httpBody = try JSONEncoder().encode(httpBody)
|
||||
print("Request body: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")")
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
case .DELETE: throw NotImplementedError.notImplemented
|
||||
}
|
||||
|
||||
var data: Data? = nil
|
||||
var response: URLResponse? = nil
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
print("Response: ", response)
|
||||
return (data, nil)
|
||||
} catch {
|
||||
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
|
||||
guard let response = response else {
|
||||
return NetworkError.unknownError
|
||||
}
|
||||
switch response.statusCode {
|
||||
case 200...299: return (nil)
|
||||
case 300...399: return (NetworkError.redirectionError)
|
||||
case 400...499: return (NetworkError.clientError)
|
||||
case 500...599: return (NetworkError.serverError)
|
||||
case 600: return (NetworkError.invalidRequest)
|
||||
default: return (NetworkError.unknownError)
|
||||
}
|
||||
}
|
||||
|
||||
func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
|
||||
do {
|
||||
let (data, error) = try await sendHTTPRequest(request)
|
||||
if let data = data {
|
||||
return (decodeData(data), error)
|
||||
}
|
||||
return (nil, error)
|
||||
} catch {
|
||||
print("An unknown network error occured.")
|
||||
}
|
||||
return (nil, NetworkError.unknownError)
|
||||
}
|
||||
|
||||
func sendRequest(_ request: RequestWrapper) async -> Error? {
|
||||
do {
|
||||
return try await sendHTTPRequest(request).1
|
||||
} catch {
|
||||
print("An unknown network error occured.")
|
||||
}
|
||||
return NetworkError.unknownError
|
||||
}
|
||||
|
||||
private func decodeData<D: Decodable>(_ data: Data) -> D? {
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
print("Decoding type ", D.self, " ...")
|
||||
return try decoder.decode(D.self, from: data)
|
||||
} catch (let error) {
|
||||
print("DataController - decodeData(): Failed to decode data.")
|
||||
print("Error: ", error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct NetworkHandler {
|
||||
static func sendHTTPRequest(_ requestWrapper: RequestWrapper, authString: String? = nil) async throws -> (Data?, NetworkError?) {
|
||||
print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.path)) ...")
|
||||
let urlStringSanitized = requestWrapper.path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
let url = URL(string: urlStringSanitized!)!
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue(
|
||||
"true",
|
||||
forHTTPHeaderField: "OCS-APIRequest"
|
||||
)
|
||||
if let authString = authString {
|
||||
request.setValue(
|
||||
"Basic \(authString)",
|
||||
forHTTPHeaderField: "Authorization"
|
||||
)
|
||||
}
|
||||
request.setValue(
|
||||
requestWrapper.accept.rawValue,
|
||||
forHTTPHeaderField: "Accept"
|
||||
)
|
||||
|
||||
request.httpMethod = requestWrapper.method.rawValue
|
||||
|
||||
switch requestWrapper.method {
|
||||
case .GET: break
|
||||
case .POST, .PUT:
|
||||
guard let httpBody = requestWrapper.body else { break }
|
||||
do {
|
||||
print("Encoding request ...")
|
||||
request.httpBody = try JSONEncoder().encode(httpBody)
|
||||
print("Request body: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")")
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
case .DELETE: throw NotImplementedError.notImplemented
|
||||
}
|
||||
|
||||
var data: Data? = nil
|
||||
var response: URLResponse? = nil
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
print("Response: ", response)
|
||||
return (data, nil)
|
||||
} catch {
|
||||
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
|
||||
}
|
||||
}
|
||||
|
||||
static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
|
||||
guard let response = response else {
|
||||
return NetworkError.unknownError
|
||||
}
|
||||
switch response.statusCode {
|
||||
case 200...299: return (nil)
|
||||
case 300...399: return (NetworkError.redirectionError)
|
||||
case 400...499: return (NetworkError.clientError)
|
||||
case 500...599: return (NetworkError.serverError)
|
||||
case 600: return (NetworkError.invalidRequest)
|
||||
default: return (NetworkError.unknownError)
|
||||
}
|
||||
}
|
||||
|
||||
static func sendDataRequest<D: Decodable>(_ request: RequestWrapper) async -> (D?, Error?) {
|
||||
do {
|
||||
let (data, error) = try await sendHTTPRequest(request)
|
||||
if let data = data {
|
||||
print(String(data: data, encoding: .utf8))
|
||||
return (decodeData(data), error)
|
||||
}
|
||||
return (nil, error)
|
||||
} catch {
|
||||
print("An unknown network error occured.")
|
||||
}
|
||||
return (nil, NetworkError.unknownError)
|
||||
}
|
||||
|
||||
private static func decodeData<D: Decodable>(_ data: Data) -> D? {
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
print("Decoding type ", D.self, " ...")
|
||||
return try decoder.decode(D.self, from: data)
|
||||
} catch (let error) {
|
||||
print("DataController - decodeData(): Failed to decode data.")
|
||||
print("Error: ", error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
79
Nextcloud Cookbook iOS Client/Network/NetworkHandler.swift
Normal file
79
Nextcloud Cookbook iOS Client/Network/NetworkHandler.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// NetworkHandler.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 13.09.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
|
||||
struct NetworkHandler {
|
||||
static func sendHTTPRequest(
|
||||
_ requestWrapper: RequestWrapper,
|
||||
hostPath: String,
|
||||
authString: String?
|
||||
) async throws -> (Data?, NetworkError?) {
|
||||
print("Sending \(requestWrapper.getMethod()) request (path: \(requestWrapper.getPath())) ...")
|
||||
|
||||
// Prepare URL
|
||||
let urlString = hostPath + requestWrapper.getPath()
|
||||
let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
let url = URL(string: urlStringSanitized!)!
|
||||
|
||||
// Create URL request
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
// Set URL method
|
||||
request.httpMethod = requestWrapper.getMethod()
|
||||
|
||||
// Set authentication string, if needed
|
||||
if let authString = authString {
|
||||
request.setValue(
|
||||
"Basic \(authString)",
|
||||
forHTTPHeaderField: "Authorization"
|
||||
)
|
||||
}
|
||||
|
||||
// Set other header fields
|
||||
for headerField in requestWrapper.getHeaderFields() {
|
||||
request.setValue(
|
||||
headerField.getValue(),
|
||||
forHTTPHeaderField: headerField.getField()
|
||||
)
|
||||
}
|
||||
|
||||
// Set http body
|
||||
if let body = requestWrapper.getBody() {
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
print("Request:\nMethod: \(request.httpMethod)\nHeaders: \(request.allHTTPHeaderFields)\nBody: \(request.httpBody)")
|
||||
|
||||
// Wait for and return data and (decoded) response
|
||||
var data: Data? = nil
|
||||
var response: URLResponse? = nil
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
print("Response: ", response)
|
||||
return (data, nil)
|
||||
} catch {
|
||||
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
|
||||
guard let response = response else {
|
||||
return NetworkError.unknownError
|
||||
}
|
||||
switch response.statusCode {
|
||||
case 200...299: return (nil)
|
||||
case 300...399: return (NetworkError.redirectionError)
|
||||
case 400...499: return (NetworkError.clientError)
|
||||
case 500...599: return (NetworkError.serverError)
|
||||
case 600: return (NetworkError.invalidRequest)
|
||||
default: return (NetworkError.unknownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,47 +8,153 @@
|
||||
import Foundation
|
||||
|
||||
enum RequestMethod: String {
|
||||
case GET = "GET", POST = "POST", PUT = "PUT", DELETE = "DELETE"
|
||||
case GET = "GET",
|
||||
POST = "POST",
|
||||
PUT = "PUT",
|
||||
DELETE = "DELETE"
|
||||
}
|
||||
|
||||
enum RequestPath: String {
|
||||
case GET_CATEGORIES = "categories"
|
||||
enum RequestPath {
|
||||
case CATEGORIES,
|
||||
RECIPE_LIST(categoryName: String),
|
||||
RECIPE_DETAIL(recipeId: Int),
|
||||
IMAGE(recipeId: Int, thumb: Bool)
|
||||
|
||||
case LOGINV2REQ,
|
||||
CUSTOM(path: String),
|
||||
NONE
|
||||
|
||||
var stringValue: String {
|
||||
switch self {
|
||||
case .CATEGORIES: return "categories"
|
||||
case .RECIPE_LIST(categoryName: let name): return "category/\(name)"
|
||||
case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)"
|
||||
case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")"
|
||||
|
||||
case .LOGINV2REQ: return "/index.php/login/v2"
|
||||
case .CUSTOM(path: let path): return path
|
||||
case .NONE: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AcceptHeader: String {
|
||||
case JSON = "application/json", IMAGE = "image/jpeg"
|
||||
enum ContentType: String {
|
||||
case JSON = "application/json",
|
||||
IMAGE = "image/jpeg",
|
||||
FORM = "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
struct HeaderField {
|
||||
private let _field: String
|
||||
private let _value: String
|
||||
|
||||
func getField() -> String {
|
||||
return _field
|
||||
}
|
||||
|
||||
func getValue() -> String {
|
||||
return _value
|
||||
}
|
||||
|
||||
static func accept(value: ContentType) -> HeaderField {
|
||||
return HeaderField(_field: "accept", _value: value.rawValue)
|
||||
}
|
||||
|
||||
static func ocsRequest(value: Bool) -> HeaderField {
|
||||
return HeaderField(_field: "OCS-APIRequest", _value: value ? "true" : "false")
|
||||
}
|
||||
|
||||
static func contentType(value: ContentType) -> HeaderField {
|
||||
return HeaderField(_field: "Content-Type", _value: value.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
struct RequestWrapper {
|
||||
let method: RequestMethod
|
||||
var path: String
|
||||
let accept: AcceptHeader
|
||||
let body: Codable?
|
||||
private let _method: RequestMethod
|
||||
private let _path: RequestPath
|
||||
private let _headerFields: [HeaderField]
|
||||
private let _body: Data?
|
||||
private let _authenticate: Bool = true
|
||||
|
||||
init(method: RequestMethod, path: String, body: Codable? = nil, accept: AcceptHeader = .JSON) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.body = body
|
||||
self.accept = accept
|
||||
private init(
|
||||
method: RequestMethod,
|
||||
path: RequestPath,
|
||||
headerFields: [HeaderField] = [],
|
||||
body: Data? = nil,
|
||||
authenticate: Bool = true
|
||||
) {
|
||||
self._method = method
|
||||
self._path = path
|
||||
self._headerFields = headerFields
|
||||
self._body = body
|
||||
}
|
||||
|
||||
func prepend(cookBookPath: String) -> String {
|
||||
return cookBookPath + self.path
|
||||
func getMethod() -> String {
|
||||
return self._method.rawValue
|
||||
}
|
||||
|
||||
func getPath() -> String {
|
||||
return self._path.stringValue
|
||||
}
|
||||
|
||||
func getHeaderFields() -> [HeaderField] {
|
||||
return self._headerFields
|
||||
}
|
||||
|
||||
func getBody() -> Data? {
|
||||
return _body
|
||||
}
|
||||
|
||||
func needsAuth() -> Bool {
|
||||
return _authenticate
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginV2Request: Codable {
|
||||
let poll: LoginV2Poll
|
||||
let login: String
|
||||
extension RequestWrapper {
|
||||
static func customRequest(
|
||||
method: RequestMethod,
|
||||
path: RequestPath,
|
||||
headerFields: [HeaderField] = [],
|
||||
body: Data? = nil,
|
||||
authenticate: Bool = true
|
||||
) -> RequestWrapper {
|
||||
let request = RequestWrapper(
|
||||
method: method,
|
||||
path: path,
|
||||
headerFields: headerFields,
|
||||
body: body,
|
||||
authenticate: authenticate
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
static func jsonGetRequest(path: RequestPath) -> RequestWrapper {
|
||||
let headerFields = [
|
||||
HeaderField.ocsRequest(value: true),
|
||||
HeaderField.accept(value: .JSON)
|
||||
]
|
||||
let request = RequestWrapper(
|
||||
method: .GET,
|
||||
path: path,
|
||||
headerFields: headerFields,
|
||||
authenticate: true
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
static func imageRequest(path: RequestPath) -> RequestWrapper {
|
||||
let headerFields = [
|
||||
HeaderField.ocsRequest(value: true),
|
||||
HeaderField.accept(value: .IMAGE)
|
||||
]
|
||||
let request = RequestWrapper(
|
||||
method: .GET,
|
||||
path: path,
|
||||
headerFields: headerFields,
|
||||
authenticate: true
|
||||
)
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginV2Poll: Codable {
|
||||
let token: String
|
||||
let endpoint: String
|
||||
}
|
||||
|
||||
struct LoginV2Response: Codable {
|
||||
let server: String
|
||||
let loginName: String
|
||||
let appPassword: String
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user