This commit is contained in:
19
server/ControllerHelpers/ApiCreatedResponse.cs
Normal file
19
server/ControllerHelpers/ApiCreatedResponse.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
|
||||
public class ApiCreatedResponse
|
||||
{
|
||||
|
||||
public object Data { get; }
|
||||
|
||||
public ApiCreatedResponse(object result)
|
||||
{
|
||||
Data = result;
|
||||
}
|
||||
}//eoc
|
||||
|
||||
|
||||
}//eons
|
||||
106
server/ControllerHelpers/ApiCustomExceptionFilter.cs
Normal file
106
server/ControllerHelpers/ApiCustomExceptionFilter.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Sockeye.Util;
|
||||
using Sockeye.Biz;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// This is essentially an unhandled exception handler
|
||||
/// </summary>
|
||||
public class ApiCustomExceptionFilter : IExceptionFilter
|
||||
{
|
||||
private readonly ILogger log;
|
||||
|
||||
// public ApiCustomExceptionFilter(ILoggerFactory logger)
|
||||
// {
|
||||
// if (logger == null)
|
||||
// {
|
||||
// throw new ArgumentNullException(nameof(logger));
|
||||
// }
|
||||
|
||||
// this.log = logger.CreateLogger("Server Exception");
|
||||
// }
|
||||
|
||||
public ApiCustomExceptionFilter(ILogger logger)
|
||||
{
|
||||
if (logger == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
this.log = logger;
|
||||
}
|
||||
|
||||
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
HttpStatusCode status = HttpStatusCode.InternalServerError;
|
||||
String message = String.Empty;
|
||||
#region If need to refine this further and deal with specific types
|
||||
// var exceptionType = context.Exception.GetType();
|
||||
// if (exceptionType == typeof(UnauthorizedAccessException))
|
||||
// {
|
||||
// message = "Unauthorized Access";
|
||||
// status = HttpStatusCode.Unauthorized;
|
||||
// }
|
||||
// else if (exceptionType == typeof(NotImplementedException))
|
||||
// {
|
||||
// message = "A server error occurred.";
|
||||
// status = HttpStatusCode.NotImplemented;
|
||||
// }
|
||||
// // else if (exceptionType == typeof(MyAppException))
|
||||
// // {
|
||||
// // message = context.Exception.ToString();
|
||||
// // status = HttpStatusCode.InternalServerError;
|
||||
// // }
|
||||
// else
|
||||
// {
|
||||
#endregion
|
||||
message = context.Exception.Message;
|
||||
status = HttpStatusCode.InternalServerError;
|
||||
//}
|
||||
|
||||
//No need to log test exceptions to check and filter out
|
||||
bool loggableError = true;
|
||||
|
||||
if (message.StartsWith("Test exception"))
|
||||
loggableError = false;
|
||||
|
||||
//LOG IT
|
||||
if (loggableError)
|
||||
log.LogError(context.Exception, "Error");
|
||||
|
||||
//Notify ops notification issue
|
||||
NotifyEventHelper.AddOpsProblemEvent("Server API internal error, see log for more details", context.Exception).Forget();//.Wait();
|
||||
|
||||
HttpResponse response = context.HttpContext.Response;
|
||||
response.StatusCode = (int)status;
|
||||
response.ContentType = "application/json; charset=utf-8";
|
||||
|
||||
//This line is critical, without it the response is not proper and fails in various clients (postman, xunit tests with httpclient)
|
||||
context.ExceptionHandled = true;
|
||||
|
||||
|
||||
response.WriteAsync(JsonConvert.SerializeObject(
|
||||
new ApiErrorResponse(ApiErrorCode.API_SERVER_ERROR, "generalerror", "Server internal error; see server log for details"),
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}//eoc
|
||||
|
||||
}//eons
|
||||
30
server/ControllerHelpers/ApiDetailError.cs
Normal file
30
server/ControllerHelpers/ApiDetailError.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Newtonsoft.Json;
|
||||
using Sockeye.Biz;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Detail error for inner part of error response
|
||||
/// </summary>
|
||||
public class ApiDetailError
|
||||
{
|
||||
/* WAIT, why does this have CODE AND Error??! */
|
||||
// [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
// public string Code { get; internal set; }
|
||||
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Message { get; internal set; }
|
||||
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Target { get; internal set; }
|
||||
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Error { get; internal set; }
|
||||
|
||||
|
||||
}//eoc
|
||||
|
||||
|
||||
}//eons
|
||||
37
server/ControllerHelpers/ApiError.cs
Normal file
37
server/ControllerHelpers/ApiError.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.Collections.Generic;
|
||||
using Sockeye.Biz;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public class ApiError
|
||||
{
|
||||
public string Code { get; }
|
||||
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public List<ApiDetailError> Details { get; internal set; }
|
||||
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Message { get; }
|
||||
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Target { get; }
|
||||
|
||||
public ApiError(ApiErrorCode apiCode, string message = null, string target = null)
|
||||
{
|
||||
Code = ((int)apiCode).ToString();
|
||||
Target = target;
|
||||
Message=message;
|
||||
}
|
||||
|
||||
}//eoc
|
||||
|
||||
|
||||
}//eons
|
||||
122
server/ControllerHelpers/ApiErrorResponse.cs
Normal file
122
server/ControllerHelpers/ApiErrorResponse.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Sockeye.Biz;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
|
||||
public class ApiErrorResponse
|
||||
{
|
||||
|
||||
[JsonIgnore]
|
||||
private ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger<ApiErrorResponse>();
|
||||
|
||||
//Mandatory properties
|
||||
public ApiError Error { get; }
|
||||
|
||||
|
||||
|
||||
|
||||
//Generic error
|
||||
public ApiErrorResponse(ApiErrorCode apiCode, string target = null, string message = null)
|
||||
{
|
||||
|
||||
//try to get a stock message if nothing specified
|
||||
if (message == null)
|
||||
{
|
||||
message = ApiErrorCodeStockMessage.GetTranslationCodeForApiErrorCode(apiCode);
|
||||
}
|
||||
|
||||
Error = new ApiError(apiCode, message, target);
|
||||
|
||||
log.LogDebug("apiCode={0}, target={1}, message={2}", apiCode, target, message);
|
||||
}
|
||||
|
||||
|
||||
//Bad request (MODELSTATE ISSUE) error response handling
|
||||
|
||||
public ApiErrorResponse(ModelStateDictionary modelState)
|
||||
{
|
||||
|
||||
if (modelState.IsValid)
|
||||
{
|
||||
throw new ArgumentException("ModelState must be invalid", nameof(modelState));
|
||||
}
|
||||
|
||||
|
||||
//Set outer error and then put validation in details
|
||||
|
||||
Error = new ApiError(ApiErrorCode.VALIDATION_FAILED, ApiErrorCodeStockMessage.GetTranslationCodeForApiErrorCode(ApiErrorCode.VALIDATION_FAILED));
|
||||
|
||||
|
||||
//https://www.jerriepelser.com/blog/validation-response-aspnet-core-webapi/
|
||||
//Message = "Validation Failed";
|
||||
Error.Details = new List<ApiDetailError>();
|
||||
|
||||
|
||||
foreach (var key in modelState.Keys)
|
||||
{
|
||||
var vErrors = modelState[key].Errors;
|
||||
foreach (ModelError m in vErrors)
|
||||
{
|
||||
string msg = "";
|
||||
if (!string.IsNullOrWhiteSpace(m.ErrorMessage))
|
||||
{
|
||||
msg += m.ErrorMessage + ". ";
|
||||
}
|
||||
if (m.Exception != null && !string.IsNullOrWhiteSpace(m.Exception.Message))
|
||||
{
|
||||
msg += "Exception: " + m.Exception.Message;
|
||||
}
|
||||
//example this produces
|
||||
//
|
||||
Error.Details.Add(new ApiDetailError() { Target = key, Message = msg, Error = ((int)ApiErrorCode.VALIDATION_INVALID_VALUE).ToString() });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
log.LogDebug("BadRequest - Validation error");
|
||||
}
|
||||
|
||||
|
||||
//Business rule validation error response
|
||||
public ApiErrorResponse(List<ValidationError> errors)
|
||||
{
|
||||
Error = new ApiError(ApiErrorCode.VALIDATION_FAILED, ApiErrorCodeStockMessage.GetTranslationCodeForApiErrorCode(ApiErrorCode.VALIDATION_FAILED));
|
||||
Error.Details = new List<ApiDetailError>();
|
||||
foreach (ValidationError v in errors)
|
||||
{
|
||||
Error.Details.Add(new ApiDetailError() { Target = v.Target, Message = v.Message, Error = ((int)v.Code).ToString() });
|
||||
}
|
||||
log.LogDebug("BadRequest - Validation error");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// public void AddDetailError(ApiErrorCode apiCode, string target = null, string message = null)
|
||||
// {
|
||||
// if (Error.Details == null)
|
||||
// {
|
||||
// Error.Details = new List<ApiDetailError>();
|
||||
// }
|
||||
|
||||
// //try to get a stock message if nothing specified
|
||||
// if (message == null)
|
||||
// {
|
||||
// message = ApiErrorCodeStockMessage.GetMessage(apiCode);
|
||||
// }
|
||||
|
||||
// Error.Details.Add(new ApiDetailError() { Code = ((int)apiCode).ToString(), Target = target, Message = message });
|
||||
// }
|
||||
|
||||
|
||||
}//eoc
|
||||
|
||||
|
||||
}//eons
|
||||
34
server/ControllerHelpers/ApiNotAuthorizedResponse.cs
Normal file
34
server/ControllerHelpers/ApiNotAuthorizedResponse.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Sockeye.Biz;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
|
||||
public class ApiNotAuthorizedResponse
|
||||
{
|
||||
|
||||
[JsonIgnore]
|
||||
private ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger<ApiNotAuthorizedResponse>();
|
||||
|
||||
//Mandatory properties
|
||||
public ApiError Error { get; }
|
||||
|
||||
//Generic error
|
||||
public ApiNotAuthorizedResponse()
|
||||
{
|
||||
Error = new ApiError(ApiErrorCode.NOT_AUTHORIZED, ApiErrorCodeStockMessage.GetTranslationCodeForApiErrorCode(ApiErrorCode.NOT_AUTHORIZED));
|
||||
|
||||
log.LogDebug("ApiErrorCode={0}, message={1}", (int)ApiErrorCode.NOT_AUTHORIZED, Error.Message);
|
||||
}
|
||||
|
||||
}//eoc
|
||||
|
||||
|
||||
}//eons
|
||||
10
server/ControllerHelpers/ApiOkResponse.cs
Normal file
10
server/ControllerHelpers/ApiOkResponse.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
public static class ApiOkResponse
|
||||
{
|
||||
public static object Response(object result)
|
||||
{
|
||||
return new { Data = result };
|
||||
}
|
||||
}//eoc
|
||||
}//eons
|
||||
220
server/ControllerHelpers/ApiServerState.cs
Normal file
220
server/ControllerHelpers/ApiServerState.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using Sockeye.Biz;
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Contains the current status of the server
|
||||
/// is injected everywhere for routes and others to check
|
||||
/// </summary>
|
||||
public class ApiServerState
|
||||
{
|
||||
|
||||
//private ILogger log = Sockeye.Util.ApplicationLogging.CreateLogger<ApiServerState>();
|
||||
public enum ServerState
|
||||
{
|
||||
///<summary>Unknown state, used for parsing</summary>
|
||||
UNKNOWN = 0,
|
||||
///<summary>No access for anyone API completely locked down. Not set by user but rather by internal server operations like importing or backup.</summary>
|
||||
Closed = 1,
|
||||
|
||||
///<summary>Access only to API Operations routes. Can be set by Ops user</summary>
|
||||
OpsOnly = 3,
|
||||
///<summary>Open for all users (default). Can be set by Ops user</summary>
|
||||
Open = 4
|
||||
}
|
||||
|
||||
private ServerState _currentState = ServerState.Closed;
|
||||
private ServerState _priorState = ServerState.Closed;
|
||||
private string _reason = string.Empty;
|
||||
private string _priorReason = string.Empty;
|
||||
private bool SYSTEM_LOCK = false;//really this is a license lock but not called that
|
||||
|
||||
public ApiServerState()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
internal void SetSystemLock(string reason)
|
||||
{
|
||||
//Lock down the server for license related issue
|
||||
//Only SuperUser account (id=1) can login or do anything, treats as if server was set to closed even if they change it to open
|
||||
//only way to reset it is to fetch a valid license
|
||||
// This is set to locked in TWO ways:
|
||||
|
||||
// 1) By CoreJobSweeper *if* the user count is mismatched to the license likely due to Users messing directly with the DB
|
||||
//trying to circumvent licensing
|
||||
// 2) By License::InitializeAsync upon finding user count mismatch or expired license key
|
||||
// and initializeasync is called on boot up or when a new key is downloaded and installed only
|
||||
//
|
||||
var msg = $"{reason}\r\nLogin as SuperUser to start evaluation / install license";
|
||||
SetState(ServerState.OpsOnly, msg);
|
||||
SYSTEM_LOCK = true;
|
||||
}
|
||||
|
||||
|
||||
//WARNING: if in future this is used for anything other than a license then it will need to see if locked for another reason before unlocking
|
||||
//recommend putting a code number in the reason then looking to see if it has the matching code
|
||||
internal void ClearSystemLock()
|
||||
{
|
||||
SYSTEM_LOCK = false;
|
||||
SetState(ServerState.Open, "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the server state
|
||||
/// </summary>
|
||||
/// <param name="newState"></param>
|
||||
/// <param name="reason"></param>
|
||||
public void SetState(ServerState newState, string reason)
|
||||
{
|
||||
//No changes allowed during a system lock
|
||||
if (SYSTEM_LOCK) return;
|
||||
|
||||
_reason = reason;//keep the reason even if the state doesn't change
|
||||
if (newState == _currentState) return;
|
||||
|
||||
//Here we will likely need to trigger a notification to users if the state is going to be shutting down or is shut down
|
||||
_priorState = _currentState;//keep the prior state so it can be resumed easily
|
||||
_priorReason = _reason;//keep the original reason
|
||||
|
||||
_currentState = newState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current state of the server
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public ServerState GetState()
|
||||
{
|
||||
return _currentState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current reason for the state of the server
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public string Reason
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_currentState == ServerState.Open)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
else
|
||||
{
|
||||
return $"\"{_reason}\"";
|
||||
// if (_currentState == ServerState.Closed)
|
||||
// return $"{ Sockeye.Biz.TranslationBiz.GetDefaultTranslationAsync("ErrorAPI2000").Result}\r\n{_reason}";
|
||||
// else //opsonly
|
||||
// return $"{ Sockeye.Biz.TranslationBiz.GetDefaultTranslationAsync("ErrorAPI2001").Result}\r\n{_reason}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//get the api error code associated with the server state
|
||||
public ApiErrorCode ApiErrorCode
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (_currentState)
|
||||
{
|
||||
case ServerState.Open:
|
||||
throw new System.NotSupportedException("ApiServerState:ApiErrorCode - No error code is associated with server state OPEN");
|
||||
case ServerState.OpsOnly:
|
||||
return ApiErrorCode.API_OPS_ONLY;
|
||||
|
||||
case ServerState.Closed:
|
||||
return ApiErrorCode.API_CLOSED;
|
||||
|
||||
}
|
||||
throw new System.NotSupportedException("ApiServerState:ApiErrorCode - No error code is associated with server state UNKNOWN");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void SetOpsOnly(string reason)
|
||||
{
|
||||
//No changes allowed during a system lock
|
||||
if (SYSTEM_LOCK) return;
|
||||
SetState(ServerState.OpsOnly, reason);
|
||||
}
|
||||
|
||||
|
||||
public void SetClosed(string reason)
|
||||
{
|
||||
//No changes allowed during a system lock
|
||||
if (SYSTEM_LOCK) return;
|
||||
SetState(ServerState.Closed, reason);
|
||||
}
|
||||
|
||||
public void SetOpen()
|
||||
{
|
||||
//No changes allowed during a system lock
|
||||
if (SYSTEM_LOCK) return;
|
||||
SetState(ServerState.Open, string.Empty);
|
||||
}
|
||||
|
||||
public void ResumePriorState()
|
||||
{
|
||||
//No changes allowed during a system lock
|
||||
if (SYSTEM_LOCK) return;
|
||||
SetState(_priorState, _priorReason);
|
||||
}
|
||||
|
||||
|
||||
public bool IsOpsOnly
|
||||
{
|
||||
get
|
||||
{
|
||||
return _currentState == ServerState.OpsOnly && !SYSTEM_LOCK;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get
|
||||
{
|
||||
return _currentState != ServerState.Closed && _currentState != ServerState.OpsOnly && !SYSTEM_LOCK;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public bool IsClosed
|
||||
{
|
||||
get
|
||||
{
|
||||
return _currentState == ServerState.Closed || SYSTEM_LOCK;
|
||||
}
|
||||
}
|
||||
|
||||
// public bool IsOpenOrOpsOnly
|
||||
// {
|
||||
// get
|
||||
// {
|
||||
// return (IsOpen || IsOpsOnly) && !SYSTEM_LOCK;
|
||||
// }
|
||||
// }
|
||||
|
||||
public bool IsSystemLocked
|
||||
{
|
||||
get
|
||||
{
|
||||
return SYSTEM_LOCK;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}//eoc
|
||||
|
||||
|
||||
}//eons
|
||||
180
server/ControllerHelpers/ApiUploadProcessor.cs
Normal file
180
server/ControllerHelpers/ApiUploadProcessor.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using System.Collections.Generic;
|
||||
using Sockeye.Models;
|
||||
using Sockeye.Util;
|
||||
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
//Adapted from the example found here: https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming
|
||||
//See AttachmentController at bottom of class for example of form that works with this code
|
||||
|
||||
/// <summary>
|
||||
/// Handle processing uplod form with potentially huge files being uploaded (which means can't use simplest built in upload handler method)
|
||||
/// </summary>
|
||||
internal static class ApiUploadProcessor
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// handle upload
|
||||
/// </summary>
|
||||
/// <param name="httpContext"></param>
|
||||
/// <returns><see cref="ApiUploadedFilesResult"/> list of files and form field data (if present)</returns>
|
||||
internal static async Task<ApiUploadedFilesResult> ProcessUploadAsync(Microsoft.AspNetCore.Http.HttpContext httpContext)
|
||||
{
|
||||
|
||||
ApiUploadedFilesResult result = new ApiUploadedFilesResult();
|
||||
FormOptions _defaultFormOptions = new FormOptions();
|
||||
try
|
||||
{
|
||||
// Used to accumulate all the form url encoded key value pairs in the
|
||||
// request.
|
||||
var formAccumulator = new KeyValueAccumulator();
|
||||
|
||||
var boundary = MultipartRequestHelper.GetBoundary(
|
||||
MediaTypeHeaderValue.Parse(httpContext.Request.ContentType),
|
||||
_defaultFormOptions.MultipartBoundaryLengthLimit);
|
||||
var reader = new MultipartReader(boundary, httpContext.Request.Body);
|
||||
|
||||
var section = await reader.ReadNextSectionAsync();
|
||||
|
||||
while (section != null)
|
||||
{
|
||||
ContentDispositionHeaderValue contentDisposition;
|
||||
var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out contentDisposition);
|
||||
|
||||
if (hasContentDispositionHeader)
|
||||
{
|
||||
if (MultipartRequestHelper.HasFileContentDisposition(contentDisposition))
|
||||
{
|
||||
|
||||
string filePathAndName = string.Empty;
|
||||
var CleanedUploadFileName = contentDisposition.FileName.Value.Replace("\"", "");
|
||||
|
||||
//get temp file path and temp file name
|
||||
filePathAndName = FileUtil.NewRandomAttachmentFilesFolderFileName;
|
||||
|
||||
//save to disk
|
||||
using (var stream = new FileStream(filePathAndName, FileMode.Create))
|
||||
{
|
||||
await section.Body.CopyToAsync(stream);
|
||||
}
|
||||
result.UploadedFiles.Add(new UploadedFileInfo()
|
||||
{
|
||||
InitialUploadedPathName = filePathAndName,
|
||||
OriginalFileName = CleanedUploadFileName,
|
||||
MimeType = section.ContentType
|
||||
|
||||
});
|
||||
}
|
||||
else if (MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition))
|
||||
{
|
||||
// Content-Disposition: form-data; name="key"
|
||||
//
|
||||
// value
|
||||
|
||||
// Do not limit the key name length here because the
|
||||
// multipart headers length limit is already in effect.
|
||||
var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name);
|
||||
var encoding = GetEncoding(section);
|
||||
using (var streamReader = new StreamReader(
|
||||
section.Body,
|
||||
encoding,
|
||||
detectEncodingFromByteOrderMarks: true,
|
||||
bufferSize: 1024,
|
||||
leaveOpen: true))
|
||||
{
|
||||
// The value length limit is enforced by MultipartBodyLengthLimit
|
||||
var value = await streamReader.ReadToEndAsync();
|
||||
if (String.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = String.Empty;
|
||||
}
|
||||
formAccumulator.Append(key.Value, value);
|
||||
|
||||
if (formAccumulator.ValueCount > _defaultFormOptions.ValueCountLimit)
|
||||
{
|
||||
throw new InvalidDataException($"Form key count limit {_defaultFormOptions.ValueCountLimit} exceeded.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drains any remaining section body that has not been consumed and
|
||||
// reads the headers for the next section.
|
||||
section = await reader.ReadNextSectionAsync();
|
||||
}
|
||||
|
||||
//Get any extra form fields and return them
|
||||
result.FormFieldData = formAccumulator.GetResults();
|
||||
return result;
|
||||
}
|
||||
catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex)
|
||||
{
|
||||
//most commonly here due to file too large
|
||||
result.Error = $"Code:{ex.StatusCode}, Error: {ex.Message}";
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static void DeleteTempUploadFile(ApiUploadedFilesResult uploadFormData)
|
||||
{
|
||||
if (uploadFormData.UploadedFiles.Count > 0)
|
||||
{
|
||||
foreach (UploadedFileInfo a in uploadFormData.UploadedFiles)
|
||||
{
|
||||
System.IO.File.Delete(a.InitialUploadedPathName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Encoding GetEncoding(MultipartSection section)
|
||||
{
|
||||
MediaTypeHeaderValue mediaType;
|
||||
var hasMediaTypeHeader = MediaTypeHeaderValue.TryParse(section.ContentType, out mediaType);
|
||||
// UTF-7 is insecure and should not be honored. UTF-8 will succeed in
|
||||
// most cases.
|
||||
if (!hasMediaTypeHeader || Encoding.UTF8.Equals(mediaType.Encoding))
|
||||
{
|
||||
return Encoding.UTF8;
|
||||
}
|
||||
return mediaType.Encoding;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Contains result of upload form processor
|
||||
/// </summary>
|
||||
public class ApiUploadedFilesResult
|
||||
{
|
||||
public Dictionary<string, Microsoft.Extensions.Primitives.StringValues> FormFieldData { get; set; }
|
||||
public List<UploadedFileInfo> UploadedFiles { get; set; }
|
||||
public string Error { get; set; }
|
||||
|
||||
public ApiUploadedFilesResult()
|
||||
{
|
||||
FormFieldData = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>();
|
||||
UploadedFiles = new List<UploadedFileInfo>();
|
||||
Error = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}//eoc
|
||||
|
||||
|
||||
}//eons
|
||||
228
server/ControllerHelpers/Authorized.cs
Normal file
228
server/ControllerHelpers/Authorized.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using EnumsNET;
|
||||
using System.Collections.Generic;
|
||||
using Sockeye.Biz;
|
||||
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
//AUTHORIZATION ROLES: NOTE - this is only 'stage1' of generally checking rights, individual objects can also have business rules that affect access exactly as these roles do
|
||||
//Most objects won't need more than this but some specialized ones will have further checks depending on biz rules
|
||||
|
||||
internal static class Authorized
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// User has any role restricted or full
|
||||
/// </summary>
|
||||
/// <param name="HttpContextItems"></param>
|
||||
/// <param name="CheckRoles"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasAnyRole(IDictionary<object, object> HttpContextItems, AuthorizationRoles CheckRoles)
|
||||
{
|
||||
AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems);
|
||||
return HasAnyRole(currentUserRoles, CheckRoles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User has any role restricted or full
|
||||
/// </summary>
|
||||
/// <param name="currentUserRoles"></param>
|
||||
/// <param name="CheckRoles"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasAnyRole(AuthorizationRoles currentUserRoles, AuthorizationRoles CheckRoles)
|
||||
{
|
||||
if (currentUserRoles.HasAnyFlags(CheckRoles))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// any access at all?
|
||||
/// </summary>
|
||||
/// <param name="HttpContextItems"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasAnyRole(IDictionary<object, object> HttpContextItems, SockType aType)
|
||||
{
|
||||
AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems);
|
||||
return HasAnyRole(currentUserRoles, aType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User has any access at all to this object?
|
||||
/// </summary>
|
||||
/// <param name="currentUserRoles"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasAnyRole(AuthorizationRoles currentUserRoles, SockType aType)
|
||||
{
|
||||
var RoleSet = BizRoles.GetRoleSet(aType);
|
||||
if (RoleSet == null) return false;
|
||||
var AllowedRoles = RoleSet.ReadFullRecord | RoleSet.Change | RoleSet.Select;
|
||||
return currentUserRoles.HasAnyFlags(AllowedRoles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// READ FULL RECORD (not just name and id)
|
||||
/// </summary>
|
||||
/// <param name="HttpContextItems"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasSelectRole(IDictionary<object, object> HttpContextItems, SockType aType)
|
||||
{
|
||||
AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems);
|
||||
return HasSelectRole(currentUserRoles, aType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SELECT BY NAME
|
||||
/// </summary>
|
||||
/// <param name="currentUserRoles"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasSelectRole(AuthorizationRoles currentUserRoles, SockType aType)
|
||||
{
|
||||
var RoleSet = BizRoles.GetRoleSet(aType);
|
||||
if (RoleSet == null) return false;
|
||||
|
||||
//NOTE: this assumes that if you can change you can read
|
||||
if (currentUserRoles.HasAnyFlags(RoleSet.Change))
|
||||
return true;
|
||||
|
||||
if (currentUserRoles.HasAnyFlags(RoleSet.ReadFullRecord))
|
||||
return true;
|
||||
|
||||
if (currentUserRoles.HasAnyFlags(RoleSet.Select))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// READ FULL RECORD (not just name and id)
|
||||
/// </summary>
|
||||
/// <param name="HttpContextItems"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasReadFullRole(IDictionary<object, object> HttpContextItems, SockType aType)
|
||||
{
|
||||
AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems);
|
||||
return HasReadFullRole(currentUserRoles, aType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// READ FULL RECORD (not just name and id)
|
||||
/// </summary>
|
||||
/// <param name="currentUserRoles"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasReadFullRole(AuthorizationRoles currentUserRoles, SockType aType)
|
||||
{
|
||||
//NOTE: this assumes that if you can change you can read
|
||||
var RoleSet = BizRoles.GetRoleSet(aType);
|
||||
if (RoleSet == null) return false;
|
||||
var AllowedRoles = RoleSet.ReadFullRecord | RoleSet.Change;
|
||||
return currentUserRoles.HasAnyFlags(AllowedRoles);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// CREATE
|
||||
/// </summary>
|
||||
/// <param name="HttpContextItems"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasCreateRole(IDictionary<object, object> HttpContextItems, SockType aType)
|
||||
{
|
||||
AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems);
|
||||
return HasCreateRole(currentUserRoles, aType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CREATE
|
||||
/// </summary>
|
||||
/// <param name="currentUserRoles"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasCreateRole(AuthorizationRoles currentUserRoles, SockType aType)
|
||||
{
|
||||
var RoleSet = BizRoles.GetRoleSet(aType);
|
||||
if (RoleSet == null) return false;
|
||||
if (currentUserRoles.HasAnyFlags(RoleSet.Change))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// MODIFY
|
||||
/// </summary>
|
||||
/// <param name="HttpContextItems"></param>
|
||||
/// <param name="aType"></param>
|
||||
|
||||
/// <returns></returns>
|
||||
internal static bool HasModifyRole(IDictionary<object, object> HttpContextItems, SockType aType)
|
||||
{
|
||||
AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems);
|
||||
return HasModifyRole(currentUserRoles, aType);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// MODIFY
|
||||
/// </summary>
|
||||
/// <param name="currentUserRoles"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
internal static bool HasModifyRole(AuthorizationRoles currentUserRoles, SockType aType)
|
||||
{
|
||||
var RoleSet = BizRoles.GetRoleSet(aType);
|
||||
if (RoleSet == null) return false;
|
||||
if (currentUserRoles.HasAnyFlags(RoleSet.Change))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// DELETE
|
||||
/// </summary>
|
||||
/// <param name="HttpContextItems"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
//For now just going to treat as a modify, but for maximum flexibility keeping this as a separate method in case we change our minds in future
|
||||
internal static bool HasDeleteRole(IDictionary<object, object> HttpContextItems, SockType aType)
|
||||
{
|
||||
AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems);
|
||||
long currentUserId = UserIdFromContext.Id(HttpContextItems);
|
||||
return HasDeleteRole(currentUserRoles, aType);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// DELETE
|
||||
/// </summary>
|
||||
/// <param name="currentUserRoles"></param>
|
||||
/// <param name="aType"></param>
|
||||
/// <returns></returns>
|
||||
//For now just going to treat as a modify, but for maximum flexibility keeping this as a separate method in case we change our minds in future
|
||||
internal static bool HasDeleteRole(AuthorizationRoles currentUserRoles, SockType aType)
|
||||
{
|
||||
var RoleSet = BizRoles.GetRoleSet(aType);
|
||||
if (RoleSet == null) return false;
|
||||
if (currentUserRoles.HasAnyFlags(RoleSet.Change))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}//eons
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
//FROM DOCS HERE:
|
||||
//https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming
|
||||
//https://github.com/aspnet/Docs/tree/74a44669d5e7039e2d4d2cb3f8b0c4ed742d1124/aspnetcore/mvc/models/file-uploads/sample/FileUploadSample
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="context"></param>
|
||||
public void OnResourceExecuting(ResourceExecutingContext context)
|
||||
{
|
||||
var formValueProviderFactory = context.ValueProviderFactories
|
||||
.OfType<FormValueProviderFactory>()
|
||||
.FirstOrDefault();
|
||||
if (formValueProviderFactory != null)
|
||||
{
|
||||
context.ValueProviderFactories.Remove(formValueProviderFactory);
|
||||
}
|
||||
|
||||
var jqueryFormValueProviderFactory = context.ValueProviderFactories
|
||||
.OfType<JQueryFormValueProviderFactory>()
|
||||
.FirstOrDefault();
|
||||
if (jqueryFormValueProviderFactory != null)
|
||||
{
|
||||
context.ValueProviderFactories.Remove(jqueryFormValueProviderFactory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="context"></param>
|
||||
public void OnResourceExecuted(ResourceExecutedContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
77
server/ControllerHelpers/MultipartRequestHelper.cs
Normal file
77
server/ControllerHelpers/MultipartRequestHelper.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public static class MultipartRequestHelper
|
||||
{
|
||||
// Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq"
|
||||
// The spec says 70 characters is a reasonable limit.
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="contentType"></param>
|
||||
/// <param name="lengthLimit"></param>
|
||||
/// <returns></returns>
|
||||
public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
|
||||
{
|
||||
var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value;
|
||||
if (string.IsNullOrWhiteSpace(boundary))
|
||||
{
|
||||
throw new InvalidDataException("Missing content-type boundary.");
|
||||
}
|
||||
|
||||
if (boundary.Length > lengthLimit)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Multipart boundary length limit {lengthLimit} exceeded.");
|
||||
}
|
||||
|
||||
return boundary;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="contentType"></param>
|
||||
/// <returns></returns>
|
||||
public static bool IsMultipartContentType(string contentType)
|
||||
{
|
||||
return !string.IsNullOrEmpty(contentType)
|
||||
&& contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="contentDisposition"></param>
|
||||
/// <returns></returns>
|
||||
public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition)
|
||||
{
|
||||
// Content-Disposition: form-data; name="key";
|
||||
return contentDisposition != null
|
||||
&& contentDisposition.DispositionType.Equals("form-data")
|
||||
&& string.IsNullOrEmpty(contentDisposition.FileName.Value)
|
||||
&& string.IsNullOrEmpty(contentDisposition.FileNameStar.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="contentDisposition"></param>
|
||||
/// <returns></returns>
|
||||
public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition)
|
||||
{
|
||||
// Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg"
|
||||
return contentDisposition != null
|
||||
&& contentDisposition.DispositionType.Equals("form-data")
|
||||
&& (!string.IsNullOrEmpty(contentDisposition.FileName.Value)
|
||||
|| !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
16
server/ControllerHelpers/UserIdFromContext.cs
Normal file
16
server/ControllerHelpers/UserIdFromContext.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
internal static class UserIdFromContext
|
||||
{
|
||||
internal static long Id(IDictionary<object, object> HttpContextItems)
|
||||
{
|
||||
|
||||
long? l = (long?)HttpContextItems["AY_USER_ID"];
|
||||
if (l==null)
|
||||
return 0L;
|
||||
return (long)l;
|
||||
}
|
||||
}
|
||||
}//eons
|
||||
20
server/ControllerHelpers/UserNameFromContext.cs
Normal file
20
server/ControllerHelpers/UserNameFromContext.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using EnumsNET;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
internal static class UserNameFromContext
|
||||
{
|
||||
internal static string Name(IDictionary<object, object> HttpContextItems)
|
||||
{
|
||||
string s = (string)HttpContextItems["AY_USERNAME"];
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
return "UNKNOWN USER NAME";
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}//eons
|
||||
17
server/ControllerHelpers/UserRolesFromContext.cs
Normal file
17
server/ControllerHelpers/UserRolesFromContext.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using Sockeye.Biz;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
internal static class UserRolesFromContext
|
||||
{
|
||||
internal static AuthorizationRoles Roles(IDictionary<object, object> HttpContextItems)
|
||||
{
|
||||
return (AuthorizationRoles)HttpContextItems["AY_ROLES"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}//eons
|
||||
15
server/ControllerHelpers/UserTranslationIdFromContext.cs
Normal file
15
server/ControllerHelpers/UserTranslationIdFromContext.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
internal static class UserTranslationIdFromContext
|
||||
{
|
||||
internal static long Id(IDictionary<object, object> HttpContextItems)
|
||||
{
|
||||
long? l = (long?)HttpContextItems["AY_TRANSLATION_ID"];
|
||||
if (l == null)
|
||||
return Sockeye.Util.ServerBootConfig.SOCKEYE_DEFAULT_TRANSLATION_ID;
|
||||
return (long)l;
|
||||
}
|
||||
}
|
||||
}//eons
|
||||
17
server/ControllerHelpers/UserTypeFromContext.cs
Normal file
17
server/ControllerHelpers/UserTypeFromContext.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using Sockeye.Biz;
|
||||
|
||||
namespace Sockeye.Api.ControllerHelpers
|
||||
{
|
||||
|
||||
|
||||
internal static class UserTypeFromContext
|
||||
{
|
||||
internal static UserType Type(IDictionary<object, object> HttpContextItems)
|
||||
{
|
||||
return (UserType)HttpContextItems["AY_USER_TYPE"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}//eons
|
||||
Reference in New Issue
Block a user