Some error handling and ensure we are an admin to delete requests.

Also started on the approval of everything
pull/13/head
tidusjar 9 years ago
parent 2935bee30d
commit 0942bfcbcc

@ -0,0 +1,48 @@
#region Copyright
// /************************************************************************
// Copyright (c) 2016 Jamie Rees
// File: ApplicationSettingsException.cs
// Created By: Jamie Rees
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// ************************************************************************/
#endregion
using System;
namespace PlexRequests.Helpers.Exceptions
{
public class ApplicationSettingsException : Exception
{
public ApplicationSettingsException(string message) : base(message)
{
}
public ApplicationSettingsException(string message, Exception innerException) : base(message, innerException)
{
}
public ApplicationSettingsException()
{
}
}
}

@ -47,6 +47,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="AssemblyHelper.cs" />
<Compile Include="Exceptions\ApplicationSettingsException.cs" />
<Compile Include="ICacheProvider.cs" />
<Compile Include="LoggingHelper.cs" />
<Compile Include="MemoryCacheProvider.cs" />
@ -59,6 +60,7 @@
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.

@ -1,5 +1,7 @@
using System;
using PlexRequests.Helpers.Exceptions;
namespace PlexRequests.Helpers
{
public static class UriHelper
@ -7,6 +9,10 @@ namespace PlexRequests.Helpers
public static Uri ReturnUri(this string val)
{
if (val == null)
{
throw new ApplicationSettingsException("The URI is null, please check your settings to make sure you have configured the applications correctly.");
}
try
{
var uri = new UriBuilder();
@ -53,6 +59,10 @@ namespace PlexRequests.Helpers
/// <exception cref="System.Exception"></exception>
public static Uri ReturnUri(this string val, int port)
{
if (val == null)
{
throw new ApplicationSettingsException("The URI is null, please check your settings to make sure you have configured the applications correctly.");
}
try
{
var uri = new UriBuilder();
@ -65,7 +75,7 @@ namespace PlexRequests.Helpers
uri = new UriBuilder(Uri.UriSchemeHttp, split[2], port, "/" + split[3]);
}
else
uri = new UriBuilder(new Uri(string.Format("{0}:{1}", val, port)));
uri = new UriBuilder(new Uri($"{val}:{port}"));
}
else if (val.StartsWith("https://", StringComparison.Ordinal))
{

@ -27,6 +27,8 @@
using System.Collections.Generic;
using System.Linq;
using NLog;
using PlexRequests.Api;
using PlexRequests.Core;
using PlexRequests.Core.SettingModels;
@ -44,9 +46,10 @@ namespace PlexRequests.Services
RequestService = request;
}
private ISettingsService<PlexSettings> Plex { get; }
private ISettingsService<AuthenticationSettings> Auth { get; }
private ISettingsService<PlexSettings> Plex { get; }
private ISettingsService<AuthenticationSettings> Auth { get; }
private IRequestService RequestService { get; }
private static Logger Log = LogManager.GetCurrentClassLogger();
public void CheckAndUpdateAll(long check)
@ -54,6 +57,18 @@ namespace PlexRequests.Services
var plexSettings = Plex.GetSettings();
var authSettings = Auth.GetSettings();
var requests = RequestService.GetAll();
if (plexSettings.Ip == null || authSettings.PlexAuthToken == null || requests == null)
{
Log.Warn("A setting is null, Ensure Plex is configured correctly, and we have a Plex Auth token.");
return;
}
if (!requests.Any())
{
Log.Info("We have no requests to check if they are available on Plex.");
return;
}
var api = new PlexApi();
var modifiedModel = new List<RequestedModel>();
@ -67,7 +82,7 @@ namespace PlexRequests.Services
modifiedModel.Add(originalRequest);
}
RequestService.BatchUpdate(modifiedModel);
RequestService.BatchUpdate(modifiedModel);
}
}
}

@ -30,6 +30,10 @@ using System.Linq;
using Dapper.Contrib.Extensions;
using NLog;
using PlexRequests.Helpers;
namespace PlexRequests.Store
{
public class GenericRepository<T> : IRepository<T> where T : Entity
@ -39,6 +43,8 @@ namespace PlexRequests.Store
Config = config;
}
private static Logger Log = LogManager.GetCurrentClassLogger();
private ISqliteConfiguration Config { get; set; }
public long Insert(T entity)
{
@ -84,6 +90,8 @@ namespace PlexRequests.Store
public bool Update(T entity)
{
Log.Trace("Updating entity");
Log.Trace(entity.DumpJson());
using (var db = Config.DbConnection())
{
db.Open();
@ -93,7 +101,9 @@ namespace PlexRequests.Store
public bool UpdateAll(IEnumerable<T> entity)
{
Log.Trace("Updating all entities");
var result = new HashSet<bool>();
using (var db = Config.DbConnection())
{
db.Open();

@ -1,4 +1,5 @@
using System;
using System.Security.Cryptography;
using Dapper.Contrib.Extensions;
@ -20,7 +21,8 @@ namespace PlexRequests.Store
public string RequestedBy { get; set; }
public DateTime RequestedDate { get; set; }
public bool Available { get; set; }
public IssueState Issues { get; set; }
public string OtherMessage { get; set; }
}
public enum RequestType
@ -28,4 +30,13 @@ namespace PlexRequests.Store
Movie,
TvShow
}
public enum IssueState
{
WrongAudio,
NoSubtitles,
WrongContent,
PlaybackIssues,
Other
}
}

@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS Requested
Type INTEGER NOT NULL,
ProviderId INTEGER NOT NULL,
ImdbId varchar(50),
Overview varchar(50) NOT NULL,
Overview varchar(50),
Title varchar(50) NOT NULL,
PosterPath varchar(50) NOT NULL,
ReleaseDate varchar(50) NOT NULL,
@ -30,7 +30,9 @@ CREATE TABLE IF NOT EXISTS Requested
Approved INTEGER NOT NULL,
RequestedBy varchar(50),
RequestedDate varchar(50) NOT NULL,
Available INTEGER(50)
Available INTEGER(50),
Issues INTEGER,
OtherMessage varchar(50)
);

@ -18,3 +18,4 @@
background-color: #4e5d6c !important;
color: white !important;
}

@ -13,6 +13,67 @@ var tvimer = 0;
movieLoad();
tvLoad();
$('#approveAll').click(function() {
$.ajax({
type: 'post',
url: '/approval/approveall',
dataType: "json",
success: function (response) {
if (checkJsonResponse(response)) {
generateNotify("Success!", "success");
}
},
error: function (e) {
console.log(e);
generateNotify("Something went wrong!", "danger");
}
});
});
// Report Issue
$(document).on("click", ".dropdownIssue", function (e) {
var issue = $(this).attr("issue-select");
var id = e.target.id;
// Other issue so the modal is opening
if (issue == 4) {
return;
}
$.ajax({
type: "post",
url: "/requests/reportissue",
data: $form.serialize(), // TODO pass in issue enum and Id
dataType: "json",
success: function (response) {
if (checkJsonResponse(response)) {
generateNotify("Success!", "success");
$("#" + buttonId + "Template").slideUp();
}
},
error: function (e) {
console.log(e);
generateNotify("Something went wrong!", "danger");
}
});
});
// Modal click
$('.theSaveButton').click(function() {
});
// Update the modal
$('#myModal').on('show.bs.modal', function(event) {
var button = $(event.relatedTarget); // Button that triggered the modal
var id = button.data('identifier'); // Extract info from data-* attributes
var modal = $(this);
modal.find('.theSaveButton').val(id);
});
$(document).on("click", ".delete", function (e) {
e.preventDefault();
var buttonId = e.target.id;
@ -24,13 +85,11 @@ $(document).on("click", ".delete", function (e) {
data: $form.serialize(),
dataType: "json",
success: function (response) {
console.log(response);
if (response.result === true) {
if (checkJsonResponse(response)) {
generateNotify("Success!", "success");
$("#" + buttonId + "Template").slideUp();
} else {
generateNotify(response.message, "warning");
}
},
error: function (e) {
@ -81,7 +140,8 @@ function buildRequestContext(result, type) {
approved: result.approved,
requestedBy: result.requestedBy,
requestedDate: result.requestedDate,
available: result.available
available: result.available,
admin: result.admin
};
return context;

@ -10,14 +10,14 @@ var searchTemplate = Handlebars.compile(searchSource);
var movieTimer = 0;
var tvimer = 0;
$("#movieSearchContent").keypress(function (e) {
$("#movieSearchContent").keypress(function () {
if (movieTimer) {
clearTimeout(movieTimer);
}
movieTimer = setTimeout(movieSearch, 400);
});
$("#tvSearchContent").keypress(function (e) {
$("#tvSearchContent").keypress(function () {
if (tvimer) {
clearTimeout(tvimer);
}
@ -26,12 +26,17 @@ $("#tvSearchContent").keypress(function (e) {
// Click TV dropdown option
$(document).on("click", ".dropdownTv", function (e) {
e.preventDefault();
var buttonId = e.target.id;
$("#" + buttonId).prop("disabled", true);
e.preventDefault();
var $form = $('#form' + buttonId);
var data = $form.serialize();
var seasons = $(this).attr("season-select");
if (seasons === "1") {
// Send over the latest
data = data + "&latest=true";
}
@ -39,14 +44,16 @@ $(document).on("click", ".dropdownTv", function (e) {
var url = $form.prop('action');
sendRequestAjax(data, type, url, buttonId);
$("#" + buttonId).prop("disabled", false);
});
// Click Request for movie
$(document).on("click", ".requestMovie", function (e) {
$(".requestMovie").prop("disabled", true);
var buttonId = e.target.id;
$("#" + buttonId).prop("disabled", true);
e.preventDefault();
var buttonId = e.target.id;
var $form = $('#form' + buttonId);
var type = $form.prop('method');
@ -54,6 +61,7 @@ $(document).on("click", ".requestMovie", function (e) {
var data = $form.serialize();
sendRequestAjax(data, type, url, buttonId);
$("#" + buttonId).prop("disabled", false);
});
function sendRequestAjax(data, type, url, buttonId) {
@ -113,3 +121,34 @@ function tvSearch() {
});
};
function buildMovieContext(result) {
var date = new Date(result.releaseDate);
var year = date.getFullYear();
var context = {
posterPath: result.posterPath,
id: result.id,
title: result.title,
overview: result.overview,
voteCount: result.voteCount,
voteAverage: result.voteAverage,
year: year,
type: "movie"
};
return context;
}
function buildTvShowContext(result) {
var date = new Date(result.firstAired);
var year = date.getFullYear();
var context = {
posterPath: result.banner,
id: result.id,
title: result.seriesName,
overview: result.overview,
year: year,
type: "tv"
};
return context;
}

@ -9,33 +9,11 @@
});
}
function buildMovieContext(result) {
var date = new Date(result.releaseDate);
var year = date.getFullYear();
var context = {
posterPath: result.posterPath,
id: result.id,
title: result.title,
overview: result.overview,
voteCount: result.voteCount,
voteAverage: result.voteAverage,
year: year,
type: "movie"
};
return context;
}
function buildTvShowContext(result) {
var date = new Date(result.firstAired);
var year = date.getFullYear();
var context = {
posterPath: result.banner,
id: result.id,
title: result.seriesName,
overview: result.overview,
year: year,
type: "tv"
};
return context;
function checkJsonResponse(response) {
if (response.result === true) {
return true;
} else {
generateNotify(response.message, "warning");
return false;
}
}

@ -0,0 +1,34 @@
#region Copyright
// /************************************************************************
// Copyright (c) 2016 Jamie Rees
// File: JsonResponseModel.cs
// Created By: Jamie Rees
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// ************************************************************************/
#endregion
namespace PlexRequests.UI.Models
{
public class JsonResponseModel
{
public bool Result { get; set; }
public string Message { get; set; }
}
}

@ -44,5 +44,6 @@ namespace PlexRequests.UI.Models
public string RequestedDate { get; set; }
public string ReleaseYear { get; set; }
public bool Available { get; set; }
public bool Admin { get; set; }
}
}

@ -0,0 +1,120 @@
#region Copyright
// /************************************************************************
// Copyright (c) 2016 Jamie Rees
// File: ApprovalModule.cs
// Created By: Jamie Rees
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// ************************************************************************/
#endregion
using System;
using System.Collections.Generic;
using System.Linq;
using Nancy;
using Nancy.Security;
using NLog;
using PlexRequests.Store;
using PlexRequests.UI.Models;
namespace PlexRequests.UI.Modules
{
public class ApprovalModule : BaseModule
{
public ApprovalModule(IRepository<RequestedModel> service) : base("approval")
{
this.RequiresAuthentication();
Service = service;
Post["/approve"] = parameters => Approve((int)Request.Form.requestid);
Post["/approveall"] = x => ApproveAll();
}
private IRepository<RequestedModel> Service { get; set; }
private static Logger Log = LogManager.GetCurrentClassLogger();
/// <summary>
/// Approves the specified request identifier.
/// </summary>
/// <param name="requestId">The request identifier.</param>
/// <returns></returns>
private Response Approve(int requestId)
{
// Get the request from the DB
var request = Service.Get(requestId);
if (request == null)
{
Log.Warn("Tried approving a request, but the request did not exist in the database, requestId = {0}", requestId);
return Response.AsJson(new JsonResponseModel { Result = false, Message = "There are no requests to approve. Please refresh." });
}
// Approve it
request.Approved = true;
// Update the record
var result = Service.Update(request);
return Response.AsJson(result
? new JsonResponseModel { Result = true }
: new JsonResponseModel { Result = false, Message = "We could not approve this request. Please try again or check the logs." });
}
/// <summary>
/// Approves all.
/// </summary>
/// <returns></returns>
private Response ApproveAll()
{
var requests = Service.GetAll();
var requestedModels = requests as RequestedModel[] ?? requests.ToArray();
if (!requestedModels.Any())
{
return Response.AsJson(new JsonResponseModel { Result = false, Message = "There are no requests to approve. Please refresh." });
}
var updatedRequests = new List<RequestedModel>();
foreach (var r in requestedModels)
{
r.Approved = true;
updatedRequests.Add(r);
}
try
{
var result = Service.UpdateAll(updatedRequests); return Response.AsJson(result
? new JsonResponseModel { Result = true }
: new JsonResponseModel { Result = false, Message = "We could not approve all of the requests. Please try again or check the logs." });
}
catch (Exception e)
{
Log.Fatal(e);
return Response.AsJson(new JsonResponseModel { Result = false, Message = "Something bad happened, please check the logs!" });
}
}
}
}

@ -32,6 +32,8 @@ using Humanizer;
using Nancy;
using Nancy.Responses.Negotiation;
using Nancy.Security;
using PlexRequests.Api;
using PlexRequests.Core;
using PlexRequests.Core.SettingModels;
@ -67,11 +69,12 @@ namespace PlexRequests.UI.Modules
private Negotiator LoadRequests()
{
var settings = PrSettings.GetSettings();
return View["Requests/Index", settings];
return View["Index", settings];
}
private Response GetMovies()
{
var isAdmin = Context.CurrentUser.IsAuthenticated();
var dbMovies = Service.GetAll().Where(x => x.Type == RequestType.Movie);
var viewModel = dbMovies.Select(movie => new RequestViewModel
{
@ -88,7 +91,8 @@ namespace PlexRequests.UI.Modules
Overview = movie.Overview,
RequestedBy = movie.RequestedBy,
ReleaseYear = movie.ReleaseDate.Year.ToString(),
Available = movie.Available
Available = movie.Available,
Admin = isAdmin
}).ToList();
return Response.AsJson(viewModel);
@ -96,6 +100,7 @@ namespace PlexRequests.UI.Modules
private Response GetTvShows()
{
var isAdmin = Context.CurrentUser.IsAuthenticated();
var dbTv = Service.GetAll().Where(x => x.Type == RequestType.TvShow);
var viewModel = dbTv.Select(tv => new RequestViewModel
{
@ -112,7 +117,8 @@ namespace PlexRequests.UI.Modules
Overview = tv.Overview,
RequestedBy = tv.RequestedBy,
ReleaseYear = tv.ReleaseDate.Year.ToString(),
Available = tv.Available
Available = tv.Available,
Admin = isAdmin
}).ToList();
return Response.AsJson(viewModel);
@ -120,9 +126,13 @@ namespace PlexRequests.UI.Modules
private Response DeleteRequest(int providerId, RequestType type)
{
var currentEntity = Service.GetAll().FirstOrDefault(x => x.ProviderId == providerId && x.Type == type);
Service.Delete(currentEntity);
return Response.AsJson(new { Result = true });
if (Context.CurrentUser.IsAuthenticated())
{
var currentEntity = Service.GetAll().FirstOrDefault(x => x.ProviderId == providerId && x.Type == type);
Service.Delete(currentEntity);
return Response.AsJson(new JsonResponseModel { Result = true });
}
return Response.AsJson(new JsonResponseModel { Result = false, Message = "You are not an Admin, so you cannot delete any requests." });
}
}
}

@ -163,6 +163,7 @@
</Content>
<Compile Include="Jobs\PlexRegistry.cs" />
<Compile Include="Jobs\PlexTaskFactory.cs" />
<Compile Include="Models\JsonResponseModel.cs" />
<Compile Include="Models\PlexAuth.cs" />
<Compile Include="Models\RequestViewModel.cs" />
<Compile Include="Models\SearchTvShowViewModel.cs" />
@ -170,6 +171,7 @@
<Compile Include="Modules\AdminModule.cs" />
<Compile Include="Modules\BaseModule.cs" />
<Compile Include="Modules\IndexModule.cs" />
<Compile Include="Modules\ApprovalModule.cs" />
<Compile Include="Modules\UserLoginModule.cs" />
<Compile Include="Modules\LoginModule.cs" />
<Compile Include="Modules\RequestsModule.cs" />
@ -217,9 +219,6 @@
<None Include="sqlite3.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<Content Include="Views\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="web.config">
<SubType>Designer</SubType>
</Content>

@ -1,6 +1,11 @@
<div>
@using Nancy.Security
<div>
<h1>Requests</h1>
<h4>Below you can see yours and all other requests, as well as their download and approval status.</h4>
@if (Context.CurrentUser.IsAuthenticated())
{
<button id="approveAll" class="btn btn-primary" type="submit"><i class="fa fa-plus"></i> Approve All</button>
}
<!-- Nav tabs -->
<ul id="nav-tabs" class="nav nav-tabs" role="tablist">
@if (Model.SearchForMovies)
@ -92,11 +97,29 @@
<div class="col-sm-2 col-sm-push-3">
<br />
<br />
{{#if_eq admin true}}
<form method="POST" action="/requests/delete" id="form{{id}}">
<input name="Id" type="text" value="{{id}}" hidden="hidden" />
<input name="Type" type="text" value="{{type}}" hidden="hidden" />
<button id="{{id}}" style="text-align: right" class="btn btn-danger delete" type="submit"><i class="fa fa-plus"></i> Remove</button>
</form>
{{/if_eq}}
{{#if_eq admin false}}
<div class="dropdown">
<button id="{{id}}" class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<i class="fa fa-plus"></i> Report Issue
<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu1">
<li><a id="{{id}}" issue-select="0" class="dropdownIssue" href="#">Wrong Audio</a></li>
<li><a id="{{id}}" issue-select="1" class="dropdownIssue" href="#">No Subtitles</a></li>
<li><a id="{{id}}" issue-select="2" class="dropdownIssue" href="#">Wrong Content</a></li>
<li><a id="{{id}}" issue-select="3" class="dropdownIssue" href="#">Playback Issues</a></li>
<li><a id="{{id}}" issue-select="4" class="dropdownIssue" data-identifier="{{id}}" href="#" data-toggle="modal" data-target="#myModal">Other</a></li>
</ul>
</div>
{{/if_eq}}
</div>
</div>
@ -104,5 +127,22 @@
</div>
</script>
<div class="modal fade" id="myModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-times"></i></button>
<h4 class="modal-title">Modal title</h4>
</div>
<div class="modal-body">
<p>One fine body</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary theSaveButton">Save changes</button>
</div>
</div>
</div>
</div>
<script src="/Content/requests.js" type="text/javascript"></script>

Loading…
Cancel
Save