diff --git a/Oqtane.Server/Pages/_Host.cshtml.cs b/Oqtane.Server/Pages/_Host.cshtml.cs
index e03ef2bd..7882eeb9 100644
--- a/Oqtane.Server/Pages/_Host.cshtml.cs
+++ b/Oqtane.Server/Pages/_Host.cshtml.cs
@@ -14,6 +14,10 @@ using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Net.Http.Headers;
+using Microsoft.AspNetCore.Http;
+using System.Security.Claims;
namespace Oqtane.Pages
{
@@ -26,8 +30,10 @@ namespace Oqtane.Pages
private readonly IAntiforgery _antiforgery;
private readonly ISiteRepository _sites;
private readonly IPageRepository _pages;
+ private readonly IUrlMappingRepository _urlMappings;
+ private readonly IVisitorRepository _visitors;
- public HostModel(IConfiguration configuration, ITenantManager tenantManager, ILocalizationManager localizationManager, ILanguageRepository languages, IAntiforgery antiforgery, ISiteRepository sites, IPageRepository pages)
+ public HostModel(IConfiguration configuration, ITenantManager tenantManager, ILocalizationManager localizationManager, ILanguageRepository languages, IAntiforgery antiforgery, ISiteRepository sites, IPageRepository pages, IUrlMappingRepository urlMappings, IVisitorRepository visitors)
{
_configuration = configuration;
_tenantManager = tenantManager;
@@ -36,11 +42,14 @@ namespace Oqtane.Pages
_antiforgery = antiforgery;
_sites = sites;
_pages = pages;
+ _urlMappings = urlMappings;
+ _visitors = visitors;
}
public string AntiForgeryToken = "";
public string Runtime = "Server";
public RenderMode RenderMode = RenderMode.Server;
+ public int VisitorId = -1;
public string HeadResources = "";
public string BodyResources = "";
public string Title = "";
@@ -48,7 +57,7 @@ namespace Oqtane.Pages
public string PWAScript = "";
public string ThemeType = "";
- public void OnGet()
+ public IActionResult OnGet()
{
AntiForgeryToken = _antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
@@ -92,6 +101,11 @@ namespace Oqtane.Pages
Title = site.Name;
ThemeType = site.DefaultThemeType;
+ if (site.VisitorTracking)
+ {
+ TrackVisitor(site.SiteId);
+ }
+
var page = _pages.GetPage(route.PagePath, site.SiteId);
if (page != null)
{
@@ -111,6 +125,37 @@ namespace Oqtane.Pages
ThemeType = page.ThemeType;
}
}
+ else
+ {
+ // page does not exist
+ var url = route.SiteUrl + "/" + route.PagePath;
+ var urlMapping = _urlMappings.GetUrlMapping(site.SiteId, url);
+ if (urlMapping == null)
+ {
+ if (site.CaptureBrokenUrls)
+ {
+ urlMapping = new UrlMapping();
+ urlMapping.SiteId = site.SiteId;
+ urlMapping.Url = url;
+ urlMapping.MappedUrl = "";
+ urlMapping.Requests = 1;
+ urlMapping.CreatedOn = DateTime.UtcNow;
+ urlMapping.RequestedOn = DateTime.UtcNow;
+ _urlMappings.AddUrlMapping(urlMapping);
+ }
+ }
+ else
+ {
+ urlMapping.Requests += 1;
+ urlMapping.RequestedOn = DateTime.UtcNow;
+ _urlMappings.UpdateUrlMapping(urlMapping);
+
+ if (!string.IsNullOrEmpty(urlMapping.MappedUrl))
+ {
+ return RedirectPermanent(urlMapping.MappedUrl);
+ }
+ }
+ }
}
// include global resources
@@ -139,6 +184,64 @@ namespace Oqtane.Pages
}
}
}
+ return Page();
+ }
+
+ private void TrackVisitor(int SiteId)
+ {
+ var VisitorCookie = "APP_VISITOR_" + SiteId.ToString();
+ if (!int.TryParse(Request.Cookies[VisitorCookie], out VisitorId))
+ {
+ var visitor = new Visitor();
+ visitor.SiteId = SiteId;
+ visitor.IPAddress = HttpContext.Connection.RemoteIpAddress.ToString();
+ visitor.UserAgent = Request.Headers[HeaderNames.UserAgent];
+ visitor.Language = Request.Headers[HeaderNames.AcceptLanguage];
+ if (visitor.Language.Contains(","))
+ {
+ visitor.Language = visitor.Language.Substring(0, visitor.Language.IndexOf(","));
+ }
+ visitor.UserId = null;
+ visitor.Visits = 1;
+ visitor.CreatedOn = DateTime.UtcNow;
+ visitor.VisitedOn = DateTime.UtcNow;
+ visitor = _visitors.AddVisitor(visitor);
+
+ Response.Cookies.Append(
+ VisitorCookie,
+ visitor.VisitorId.ToString(),
+ new CookieOptions()
+ {
+ Expires = DateTimeOffset.UtcNow.AddYears(1),
+ IsEssential = true
+ }
+ );
+ }
+ else
+ {
+ var visitor = _visitors.GetVisitor(VisitorId);
+ if (visitor != null)
+ {
+ visitor.IPAddress = HttpContext.Connection.RemoteIpAddress.ToString();
+ visitor.UserAgent = Request.Headers[HeaderNames.UserAgent];
+ visitor.Language = Request.Headers[HeaderNames.AcceptLanguage];
+ if (visitor.Language.Contains(","))
+ {
+ visitor.Language = visitor.Language.Substring(0, visitor.Language.IndexOf(","));
+ }
+ if (User.HasClaim(item => item.Type == ClaimTypes.PrimarySid))
+ {
+ visitor.UserId = int.Parse(User.Claims.First(item => item.Type == ClaimTypes.PrimarySid).Value);
+ }
+ visitor.Visits += 1;
+ visitor.VisitedOn = DateTime.UtcNow;
+ _visitors.UpdateVisitor(visitor);
+ }
+ else
+ {
+ Response.Cookies.Delete(VisitorCookie);
+ }
+ }
}
private string CreatePWAScript(Alias alias, Site site, Route route)
diff --git a/Oqtane.Server/Repository/Context/TenantDBContext.cs b/Oqtane.Server/Repository/Context/TenantDBContext.cs
index ca8c822d..dc2c219b 100644
--- a/Oqtane.Server/Repository/Context/TenantDBContext.cs
+++ b/Oqtane.Server/Repository/Context/TenantDBContext.cs
@@ -29,5 +29,7 @@ namespace Oqtane.Repository
public virtual DbSet
Folder { get; set; }
public virtual DbSet File { get; set; }
public virtual DbSet Language { get; set; }
+ public virtual DbSet Visitor { get; set; }
+ public virtual DbSet UrlMapping { get; set; }
}
}
diff --git a/Oqtane.Server/Repository/Interfaces/IUrlMappingRepository.cs b/Oqtane.Server/Repository/Interfaces/IUrlMappingRepository.cs
new file mode 100644
index 00000000..f954d87b
--- /dev/null
+++ b/Oqtane.Server/Repository/Interfaces/IUrlMappingRepository.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using Oqtane.Models;
+
+namespace Oqtane.Repository
+{
+ public interface IUrlMappingRepository
+ {
+ IEnumerable GetUrlMappings(int siteId, bool isMapped);
+ UrlMapping AddUrlMapping(UrlMapping urlMapping);
+ UrlMapping UpdateUrlMapping(UrlMapping urlMapping);
+ UrlMapping GetUrlMapping(int urlMappingId);
+ UrlMapping GetUrlMapping(int urlMappingId, bool tracking);
+ UrlMapping GetUrlMapping(int siteId, string url);
+ void DeleteUrlMapping(int urlMappingId);
+ }
+}
diff --git a/Oqtane.Server/Repository/Interfaces/IVisitorRepository.cs b/Oqtane.Server/Repository/Interfaces/IVisitorRepository.cs
new file mode 100644
index 00000000..d50ceec2
--- /dev/null
+++ b/Oqtane.Server/Repository/Interfaces/IVisitorRepository.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+using Oqtane.Models;
+
+namespace Oqtane.Repository
+{
+ public interface IVisitorRepository
+ {
+ IEnumerable GetVisitors(int siteId, DateTime fromDate);
+ Visitor AddVisitor(Visitor visitor);
+ Visitor UpdateVisitor(Visitor visitor);
+ Visitor GetVisitor(int visitorId);
+ void DeleteVisitor(int visitorId);
+ }
+}
diff --git a/Oqtane.Server/Repository/SiteRepository.cs b/Oqtane.Server/Repository/SiteRepository.cs
index bbf8c856..0fcf9cef 100644
--- a/Oqtane.Server/Repository/SiteRepository.cs
+++ b/Oqtane.Server/Repository/SiteRepository.cs
@@ -615,13 +615,70 @@ namespace Oqtane.Repository
}
}
});
+ pageTemplates.Add(new PageTemplate
+ {
+ Name = "Url Mappings",
+ Parent = "Admin",
+ Order = 15,
+ Path = "admin/urlmappings",
+ Icon = Icons.LinkBroken,
+ IsNavigation = true,
+ IsPersonalizable = false,
+ PagePermissions = new List
+ {
+ new Permission(PermissionNames.View, RoleNames.Admin, true),
+ new Permission(PermissionNames.Edit, RoleNames.Admin, true)
+ }.EncodePermissions(),
+ PageTemplateModules = new List
+ {
+ new PageTemplateModule
+ {
+ ModuleDefinitionName = typeof(Oqtane.Modules.Admin.UrlMappings.Index).ToModuleDefinitionName(), Title = "Url Mappings", Pane = PaneNames.Admin,
+ ModulePermissions = new List
+ {
+ new Permission(PermissionNames.View, RoleNames.Admin, true),
+ new Permission(PermissionNames.Edit, RoleNames.Admin, true)
+ }.EncodePermissions(),
+ Content = ""
+ }
+ }
+ });
+
+ pageTemplates.Add(new PageTemplate
+ {
+ Name = "Visitor Management",
+ Parent = "Admin",
+ Order = 17,
+ Path = "admin/visitors",
+ Icon = Icons.Eye,
+ IsNavigation = true,
+ IsPersonalizable = false,
+ PagePermissions = new List
+ {
+ new Permission(PermissionNames.View, RoleNames.Admin, true),
+ new Permission(PermissionNames.Edit, RoleNames.Admin, true)
+ }.EncodePermissions(),
+ PageTemplateModules = new List
+ {
+ new PageTemplateModule
+ {
+ ModuleDefinitionName = typeof(Oqtane.Modules.Admin.Visitors.Index).ToModuleDefinitionName(), Title = "Visitor Management", Pane = PaneNames.Admin,
+ ModulePermissions = new List
+ {
+ new Permission(PermissionNames.View, RoleNames.Admin, true),
+ new Permission(PermissionNames.Edit, RoleNames.Admin, true)
+ }.EncodePermissions(),
+ Content = ""
+ }
+ }
+ });
// host pages
pageTemplates.Add(new PageTemplate
{
Name = "Event Log",
Parent = "Admin",
- Order = 15,
+ Order = 19,
Path = "admin/log",
Icon = Icons.MagnifyingGlass,
IsNavigation = false,
@@ -649,7 +706,7 @@ namespace Oqtane.Repository
{
Name = "Site Management",
Parent = "Admin",
- Order = 17,
+ Order = 21,
Path = "admin/sites",
Icon = Icons.Globe,
IsNavigation = false,
@@ -677,7 +734,7 @@ namespace Oqtane.Repository
{
Name = "Module Management",
Parent = "Admin",
- Order = 19,
+ Order = 23,
Path = "admin/modules",
Icon = Icons.Browser,
IsNavigation = false,
@@ -705,7 +762,7 @@ namespace Oqtane.Repository
{
Name = "Theme Management",
Parent = "Admin",
- Order = 21,
+ Order = 25,
Path = "admin/themes",
Icon = Icons.Brush,
IsNavigation = false,
@@ -733,7 +790,7 @@ namespace Oqtane.Repository
{
Name = "Language Management",
Parent = "Admin",
- Order = 23,
+ Order = 27,
Path = "admin/languages",
Icon = Icons.Text,
IsNavigation = false,
@@ -765,7 +822,7 @@ namespace Oqtane.Repository
{
Name = "Scheduled Jobs",
Parent = "Admin",
- Order = 25,
+ Order = 29,
Path = "admin/jobs",
Icon = Icons.Timer,
IsNavigation = false,
@@ -793,7 +850,7 @@ namespace Oqtane.Repository
{
Name = "Sql Management",
Parent = "Admin",
- Order = 27,
+ Order = 31,
Path = "admin/sql",
Icon = Icons.Spreadsheet,
IsNavigation = false,
@@ -821,7 +878,7 @@ namespace Oqtane.Repository
{
Name = "System Info",
Parent = "Admin",
- Order = 29,
+ Order = 33,
Path = "admin/system",
Icon = Icons.MedicalCross,
IsNavigation = false,
@@ -849,7 +906,7 @@ namespace Oqtane.Repository
{
Name = "System Update",
Parent = "Admin",
- Order = 31,
+ Order = 35,
Path = "admin/update",
Icon = Icons.Aperture,
IsNavigation = false,
diff --git a/Oqtane.Server/Repository/UrlMappingRepository.cs b/Oqtane.Server/Repository/UrlMappingRepository.cs
new file mode 100644
index 00000000..306d8f69
--- /dev/null
+++ b/Oqtane.Server/Repository/UrlMappingRepository.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.EntityFrameworkCore;
+using Oqtane.Models;
+
+namespace Oqtane.Repository
+{
+ public class UrlMappingRepository : IUrlMappingRepository
+ {
+ private TenantDBContext _db;
+
+ public UrlMappingRepository(TenantDBContext context)
+ {
+ _db = context;
+ }
+
+ public IEnumerable GetUrlMappings(int siteId, bool isMapped)
+ {
+ if (isMapped)
+ {
+ return _db.UrlMapping.Where(item => item.SiteId == siteId && !string.IsNullOrEmpty(item.MappedUrl)).Take(200);
+ }
+ else
+ {
+ return _db.UrlMapping.Where(item => item.SiteId == siteId && string.IsNullOrEmpty(item.MappedUrl)).Take(200);
+ }
+ }
+
+ public UrlMapping AddUrlMapping(UrlMapping urlMapping)
+ {
+ _db.UrlMapping.Add(urlMapping);
+ _db.SaveChanges();
+ return urlMapping;
+ }
+
+ public UrlMapping UpdateUrlMapping(UrlMapping urlMapping)
+ {
+ _db.Entry(urlMapping).State = EntityState.Modified;
+ _db.SaveChanges();
+ return urlMapping;
+ }
+
+ public UrlMapping GetUrlMapping(int urlMappingId)
+ {
+ return GetUrlMapping(urlMappingId, true);
+ }
+
+ public UrlMapping GetUrlMapping(int urlMappingId, bool tracking)
+ {
+ if (tracking)
+ {
+ return _db.UrlMapping.Find(urlMappingId);
+ }
+ else
+ {
+ return _db.UrlMapping.AsNoTracking().FirstOrDefault(item => item.UrlMappingId == urlMappingId);
+ }
+ }
+
+ public UrlMapping GetUrlMapping(int siteId, string url)
+ {
+ return _db.UrlMapping.Where(item => item.SiteId == siteId && item.Url == url).FirstOrDefault();
+ }
+
+ public void DeleteUrlMapping(int urlMappingId)
+ {
+ UrlMapping urlMapping = _db.UrlMapping.Find(urlMappingId);
+ _db.UrlMapping.Remove(urlMapping);
+ _db.SaveChanges();
+ }
+ }
+}
diff --git a/Oqtane.Server/Repository/VisitorRepository.cs b/Oqtane.Server/Repository/VisitorRepository.cs
new file mode 100644
index 00000000..254c48e7
--- /dev/null
+++ b/Oqtane.Server/Repository/VisitorRepository.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.EntityFrameworkCore;
+using Oqtane.Models;
+
+namespace Oqtane.Repository
+{
+ public class VisitorRepository : IVisitorRepository
+ {
+ private TenantDBContext _db;
+
+ public VisitorRepository(TenantDBContext context)
+ {
+ _db = context;
+ }
+
+ public IEnumerable GetVisitors(int siteId, DateTime fromDate)
+ {
+ return _db.Visitor.AsNoTracking()
+ .Include(item => item.User) // eager load users
+ .Where(item => item.SiteId == siteId && item.VisitedOn >= fromDate);
+ }
+
+ public Visitor AddVisitor(Visitor visitor)
+ {
+ _db.Visitor.Add(visitor);
+ _db.SaveChanges();
+ return visitor;
+ }
+
+ public Visitor UpdateVisitor(Visitor visitor)
+ {
+ _db.Entry(visitor).State = EntityState.Modified;
+ _db.SaveChanges();
+ return visitor;
+ }
+
+ public Visitor GetVisitor(int visitorId)
+ {
+ return _db.Visitor.Find(visitorId);
+ }
+
+ public void DeleteVisitor(int visitorId)
+ {
+ Visitor visitor = _db.Visitor.Find(visitorId);
+ _db.Visitor.Remove(visitor);
+ _db.SaveChanges();
+ }
+ }
+}
diff --git a/Oqtane.Shared/Models/Site.cs b/Oqtane.Shared/Models/Site.cs
index 5ccf11d0..eb7682a2 100644
--- a/Oqtane.Shared/Models/Site.cs
+++ b/Oqtane.Shared/Models/Site.cs
@@ -49,10 +49,20 @@ namespace Oqtane.Models
public int? PwaSplashIconFileId { get; set; }
///
- /// Determines if users may register / create accounts
+ /// Determines if visitors may register / create user accounts
///
public bool AllowRegistration { get; set; }
+ ///
+ /// Determines if visitors will be tracked
+ ///
+ public bool VisitorTracking { get; set; }
+
+ ///
+ /// Determines if broken urls (404s) will be captured automatically
+ ///
+ public bool CaptureBrokenUrls { get; set; }
+
///
/// Unique GUID to identify the Site.
///
diff --git a/Oqtane.Shared/Models/UrlMapping.cs b/Oqtane.Shared/Models/UrlMapping.cs
new file mode 100644
index 00000000..b5a15384
--- /dev/null
+++ b/Oqtane.Shared/Models/UrlMapping.cs
@@ -0,0 +1,47 @@
+using System;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Oqtane.Models
+{
+ ///
+ /// Describes a UrlMapping in Oqtane.
+ ///
+ public class UrlMapping
+ {
+ ///
+ /// ID of this UrlMapping.
+ ///
+ public int UrlMappingId { get; set; }
+
+ ///
+ /// Reference to a
+ ///
+ public int SiteId { get; set; }
+
+ ///
+ /// A fully quaified Url
+ ///
+ public string Url { get; set; }
+
+ ///
+ /// A Url the visitor will be redirected to
+ ///
+ public string MappedUrl { get; set; }
+
+ ///
+ /// Number of requests all time for the url
+ ///
+ public int Requests { get; set; }
+
+ ///
+ /// Date when the url was first requested for the site
+ ///
+ public DateTime CreatedOn { get; set; }
+
+ ///
+ /// Date when the url was last requested for the site
+ ///
+ public DateTime RequestedOn { get; set; }
+
+ }
+}
diff --git a/Oqtane.Shared/Models/Visitor.cs b/Oqtane.Shared/Models/Visitor.cs
new file mode 100644
index 00000000..b27fdca5
--- /dev/null
+++ b/Oqtane.Shared/Models/Visitor.cs
@@ -0,0 +1,61 @@
+using System;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Oqtane.Models
+{
+ ///
+ /// Describes a Visitor in Oqtane.
+ ///
+ public class Visitor
+ {
+ ///
+ /// ID of this Visitor.
+ ///
+ public int VisitorId { get; set; }
+
+ ///
+ /// Reference to a
+ ///
+ public int SiteId { get; set; }
+
+ ///
+ /// Reference to a if applicable
+ ///
+ public int? UserId { get; set; }
+
+ ///
+ /// Number of times a visitor has visited a site
+ ///
+ public int Visits { get; set; }
+
+ ///
+ /// IP Address of visitor
+ ///
+ public string IPAddress { get; set; }
+
+ ///
+ /// User agent of visitor
+ ///
+ public string UserAgent { get; set; }
+
+ ///
+ /// Language of visitor
+ ///
+ public string Language { get; set; }
+
+ ///
+ /// Date the visitor first visited the site
+ ///
+ public DateTime CreatedOn { get; set; }
+
+ ///
+ /// Date the visitor last visited the site
+ ///
+ public DateTime VisitedOn { get; set; }
+
+ ///
+ /// Direct reference to the object (if applicable)
+ ///
+ public User User { get; set; }
+ }
+}
diff --git a/Oqtane.Shared/Shared/Constants.cs b/Oqtane.Shared/Shared/Constants.cs
index 9a2c19cc..eef3e4e1 100644
--- a/Oqtane.Shared/Shared/Constants.cs
+++ b/Oqtane.Shared/Shared/Constants.cs
@@ -3,8 +3,8 @@ using System;
namespace Oqtane.Shared {
public class Constants {
- public static readonly string Version = "3.0.0";
- public const string ReleaseVersions = "1.0.0,1.0.1,1.0.2,1.0.3,1.0.4,2.0.0,2.0.1,2.0.2,2.1.0,2.2.0,2.3.0,2.3.1,3.0.0";
+ public static readonly string Version = "3.0.1";
+ public const string ReleaseVersions = "1.0.0,1.0.1,1.0.2,1.0.3,1.0.4,2.0.0,2.0.1,2.0.2,2.1.0,2.2.0,2.3.0,2.3.1,3.0.0,3.0.1";
public const string PackageId = "Oqtane.Framework";
public const string UpdaterPackageId = "Oqtane.Updater";
public const string PackageRegistryUrl = "https://www.oqtane.net";
diff --git a/Oqtane.Shared/Shared/EntityNames.cs b/Oqtane.Shared/Shared/EntityNames.cs
index 345267f3..5efbd716 100644
--- a/Oqtane.Shared/Shared/EntityNames.cs
+++ b/Oqtane.Shared/Shared/EntityNames.cs
@@ -1,4 +1,4 @@
-namespace Oqtane.Shared
+namespace Oqtane.Shared
{
public class EntityNames
{
@@ -10,5 +10,6 @@
public const string Page = "Page";
public const string Folder = "Folder";
public const string User = "User";
+ public const string Visitor = "Visitor";
}
}