Initial commit

This commit is contained in:
snow flurry 2023-10-17 21:55:10 -07:00
commit 1f92f82b2c
59 changed files with 3211 additions and 0 deletions

454
.gitignore vendored Normal file
View file

@ -0,0 +1,454 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# JetBrains Rider
.idea/
*.sln.iml
##
## Visual Studio Code
##
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

View file

@ -0,0 +1,7 @@
namespace PhoneToolMX.Models
{
[System.AttributeUsage(AttributeTargets.Property)]
public class AlwaysIncludeAttribute : System.Attribute
{
}
}

View file

@ -0,0 +1,25 @@
using PhoneToolMX.Models.ViewModels;
using System.ComponentModel.DataAnnotations;
using System.Data;
namespace PhoneToolMX.Models
{
public class CheckExtensionsAttribute : ValidationAttribute
{
private int? MaxExtensions;
public override string FormatErrorMessage(string name)
{
return MaxExtensions != null
? $"Up to {MaxExtensions} extensions can be assigned to this phone"
: "Too many extensions assigned to this phone";
}
public override bool IsValid(object value)
{
if (value is not PhoneVM phone) throw new ConstraintException($"{GetType().Name} is only allowed for PhoneVM");
if (phone.MaxExtensions <= 0 || phone.Extensions == null) return true;
MaxExtensions = phone.MaxExtensions;
return !(phone.Extensions.Count > phone.MaxExtensions);
}
}
}

View file

@ -0,0 +1,126 @@
// using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using PhoneToolMX.Models;
using System.Net.NetworkInformation;
// ReSharper disable CheckNamespace
// ReSharper disable UnusedAutoPropertyAccessor.Global
namespace PhoneToolMX.Data {
public class PTMXContext : DbContext
{
public DbSet<Phone> Phones { get; set; }
public DbSet<PhoneModel> PhoneModels { get; set; }
public DbSet<Extension> Extensions { get; set; }
public DbSet<CustomData> CustomData { get; set; }
public DbSet<User> Users { get; set; }
public PTMXContext(DbContextOptions<PTMXContext> options) : base(options)
{
}
#region Public helpers
/// <summary>
/// Adds an <see cref="IOwnedModel"/> entity to the database, marking the given <see cref="User"/> as its owner.
/// </summary>
/// <param name="owner">The <see cref="User"/> that owns this entity.</param>
/// <param name="entity">The entity to be created.</param>
/// <typeparam name="TEntity">A model conforming to <see cref="IOwnedModel"/>.</typeparam>
/// <returns>The entity entry for the created entity.</returns>
public async Task<EntityEntry<TEntity>> AddOwnable<TEntity>(User owner, TEntity entity) where TEntity: OwnedBase
{
var set = Set<TEntity>();
entity.Owners ??= new List<User>();
entity.Owners.Add(owner);
var entry = await AddAsync(entity);
return entry;
}
/// <summary>
/// Gets all entities of a certain model owned by the <see cref="User"/>.
/// </summary>
/// <param name="owner">The <see cref="User"/> object to be considered the owner.</param>
/// <typeparam name="TEntity">A model conforming to <see cref="IOwnedModel"/></typeparam>
/// <returns>All entities of the model owned by the given user</returns>
public ICollection<TEntity> GetOwned<TEntity>(User owner) where TEntity : class, IOwnedModel
{
if (owner == null) return null;
var entity = Set<TEntity>().Where(x => x.Owners.Any(o => o.Id == owner.Id));
// eager load all w/ AlwaysInclude
entity = typeof(TEntity).GetProperties()
.Where(p => p.GetCustomAttributes(typeof(AlwaysIncludeAttribute), true)
.Length != 0)
.Aggregate(entity, (current, prop) => current.Include(prop.Name));
return entity.ToList();
}
/// <summary>
/// Gets a model entity by its Id
/// </summary>
/// <param name="id">Id of the model, or null if you're like that</param>
/// <typeparam name="TEntity">A model confirming to <see cref="IModel"/> that has a given DbSet</typeparam>
/// <returns>The model defined by the Id, or null if none exist.</returns>
/// <remarks>If null is provided as the id, null will be returned. <c>id</c> is nullable solely for compatibility.
/// </remarks>
public TEntity GetEntityById<TEntity>(int? id) where TEntity : class, IModel
{
return id == null ? null : Set<TEntity>().FirstOrDefault(o => o.Id == id);
}
#endregion
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Core of PTMX: Phones and extensions
var ext = modelBuilder.Entity<Extension>();
ext.HasKey(x => x.Id);
ext.Property(x => x.Id).UseIdentityColumn();
ext.Property(x => x.ExtId).HasComputedColumnSql("\"Id\" + 1000", stored: true);
var phone = modelBuilder.Entity<Phone>();
phone
.HasMany(p => p.Extensions)
.WithMany(x => x.Phones);
phone.HasKey(p => p.Id);
phone.Property(p => p.Id).UseIdentityColumn();
// Randomly generates a 24-char password
phone
.Property(p => p.Password)
.HasDefaultValueSql("encode(gen_random_bytes(18), 'base64')");
// Wallpapers, ringtones, etc
var cd = modelBuilder.Entity<CustomData>();
cd.HasKey(c => c.Id);
cd.Property(c => c.Id).UseIdentityColumn();
// Phone models, for custom tests
var pm = modelBuilder.Entity<PhoneModel>();
pm.HasKey(p => p.Id);
pm.Property(p => p.Id).UseIdentityColumn();
pm.HasData(new PhoneModel
{
Id = 0,
ModelName = "Polycom VVX300/310",
MaxExtensions = 6,
PreVvxPolycom = false,
});
// Authz/RBAC
var userEnt = modelBuilder.Entity<User>();
userEnt
.HasMany(u => u.Phones)
.WithMany(p => p.Owners);
userEnt
.HasMany(u => u.Extensions)
.WithMany(x => x.Owners);
userEnt
.HasKey(u => u.Id);
modelBuilder.Entity<Role>()
.HasKey(r => r.Id);
}
}
}

View file

@ -0,0 +1,16 @@
namespace PhoneToolMX.Models
{
[System.AttributeUsage(AttributeTargets.Property)]
public class HeaderAttribute : System.Attribute
{
public string Title { get; set; }
public bool Primary { get; set; }
public bool Small { get; set; }
public HeaderAttribute(string title)
{
Title = title;
}
public HeaderAttribute() {}
}
}

View file

@ -0,0 +1,358 @@
// <auto-generated />
using System;
using System.Net.NetworkInformation;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using PhoneToolMX.Data;
#nullable disable
namespace PhoneToolMX.Models.Migrations
{
[DbContext(typeof(PTMXContext))]
[Migration("20231015015926_InitialCreate")]
partial class InitialCreate
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.16")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ExtensionPhone", b =>
{
b.Property<int>("ExtensionsId")
.HasColumnType("integer");
b.Property<int>("PhonesId")
.HasColumnType("integer");
b.HasKey("ExtensionsId", "PhonesId");
b.HasIndex("PhonesId");
b.ToTable("ExtensionPhone");
});
modelBuilder.Entity("ExtensionUser", b =>
{
b.Property<int>("ExtensionsId")
.HasColumnType("integer");
b.Property<string>("OwnersId")
.HasColumnType("text");
b.HasKey("ExtensionsId", "OwnersId");
b.HasIndex("OwnersId");
b.ToTable("ExtensionUser");
});
modelBuilder.Entity("PhoneToolMX.Models.CustomData", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Data")
.HasColumnType("bytea");
b.Property<int>("DataType")
.HasColumnType("integer");
b.Property<string>("FriendlyName")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<int?>("PhoneId")
.HasColumnType("integer");
b.Property<long>("Size")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("PhoneId");
b.ToTable("CustomData");
});
modelBuilder.Entity("PhoneToolMX.Models.Extension", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<string>("DirectoryName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ExtId")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("integer")
.HasComputedColumnSql("\"Id\" + 1000", true);
b.Property<int?>("HoldMusicId")
.HasColumnType("integer");
b.Property<bool>("Listed")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("HoldMusicId");
b.ToTable("Extensions");
});
modelBuilder.Entity("PhoneToolMX.Models.Phone", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<int?>("BackgroundId")
.HasColumnType("integer");
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("text");
b.Property<PhysicalAddress>("MacAddress")
.IsRequired()
.HasColumnType("macaddr");
b.Property<int>("ModelId")
.HasColumnType("integer");
b.Property<string>("Password")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValueSql("encode(gen_random_bytes(18), 'base64')");
b.HasKey("Id");
b.HasIndex("BackgroundId");
b.HasIndex("ModelId");
b.ToTable("Phones");
});
modelBuilder.Entity("PhoneToolMX.Models.PhoneModel", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<long>("MaxExtensions")
.HasColumnType("bigint");
b.Property<string>("ModelName")
.HasColumnType("text");
b.Property<bool>("PreVvxPolycom")
.HasColumnType("boolean");
b.HasKey("Id");
b.ToTable("PhoneModels");
b.HasData(
new
{
Id = 0,
MaxExtensions = 6L,
ModelName = "Polycom VVX300/310",
PreVvxPolycom = false
});
});
modelBuilder.Entity("PhoneToolMX.Models.Role", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NormalizedName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Role");
});
modelBuilder.Entity("PhoneToolMX.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("PhoneUser", b =>
{
b.Property<string>("OwnersId")
.HasColumnType("text");
b.Property<int>("PhonesId")
.HasColumnType("integer");
b.HasKey("OwnersId", "PhonesId");
b.HasIndex("PhonesId");
b.ToTable("PhoneUser");
});
modelBuilder.Entity("ExtensionPhone", b =>
{
b.HasOne("PhoneToolMX.Models.Extension", null)
.WithMany()
.HasForeignKey("ExtensionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PhoneToolMX.Models.Phone", null)
.WithMany()
.HasForeignKey("PhonesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ExtensionUser", b =>
{
b.HasOne("PhoneToolMX.Models.Extension", null)
.WithMany()
.HasForeignKey("ExtensionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PhoneToolMX.Models.User", null)
.WithMany()
.HasForeignKey("OwnersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("PhoneToolMX.Models.CustomData", b =>
{
b.HasOne("PhoneToolMX.Models.Phone", null)
.WithMany("Ringtones")
.HasForeignKey("PhoneId");
});
modelBuilder.Entity("PhoneToolMX.Models.Extension", b =>
{
b.HasOne("PhoneToolMX.Models.CustomData", "HoldMusic")
.WithMany()
.HasForeignKey("HoldMusicId");
b.Navigation("HoldMusic");
});
modelBuilder.Entity("PhoneToolMX.Models.Phone", b =>
{
b.HasOne("PhoneToolMX.Models.CustomData", "Background")
.WithMany()
.HasForeignKey("BackgroundId");
b.HasOne("PhoneToolMX.Models.PhoneModel", "Model")
.WithMany()
.HasForeignKey("ModelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Background");
b.Navigation("Model");
});
modelBuilder.Entity("PhoneUser", b =>
{
b.HasOne("PhoneToolMX.Models.User", null)
.WithMany()
.HasForeignKey("OwnersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PhoneToolMX.Models.Phone", null)
.WithMany()
.HasForeignKey("PhonesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("PhoneToolMX.Models.Phone", b =>
{
b.Navigation("Ringtones");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,288 @@
using System;
using System.Net.NetworkInformation;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace PhoneToolMX.Models.Migrations
{
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PhoneModels",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ModelName = table.Column<string>(type: "text", nullable: true),
MaxExtensions = table.Column<long>(type: "bigint", nullable: false),
PreVvxPolycom = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PhoneModels", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Role",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: true),
NormalizedName = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Role", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
UserName = table.Column<string>(type: "text", nullable: true),
NormalizedUserName = table.Column<string>(type: "text", nullable: true),
Email = table.Column<string>(type: "text", nullable: true),
NormalizedEmail = table.Column<string>(type: "text", nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CustomData",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FriendlyName = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
DataType = table.Column<int>(type: "integer", nullable: false),
Size = table.Column<long>(type: "bigint", nullable: false),
Data = table.Column<byte[]>(type: "bytea", nullable: true),
PhoneId = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CustomData", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Extensions",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ExtId = table.Column<int>(type: "integer", nullable: false, computedColumnSql: "\"Id\" + 1000", stored: true),
DirectoryName = table.Column<string>(type: "text", nullable: false),
Listed = table.Column<bool>(type: "boolean", nullable: false),
HoldMusicId = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Extensions", x => x.Id);
table.ForeignKey(
name: "FK_Extensions_CustomData_HoldMusicId",
column: x => x.HoldMusicId,
principalTable: "CustomData",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Phones",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MacAddress = table.Column<PhysicalAddress>(type: "macaddr", nullable: false),
FriendlyName = table.Column<string>(type: "text", nullable: false),
ModelId = table.Column<int>(type: "integer", nullable: false),
BackgroundId = table.Column<int>(type: "integer", nullable: true),
Password = table.Column<string>(type: "text", nullable: true, defaultValueSql: "encode(gen_random_bytes(18), 'base64')")
},
constraints: table =>
{
table.PrimaryKey("PK_Phones", x => x.Id);
table.ForeignKey(
name: "FK_Phones_CustomData_BackgroundId",
column: x => x.BackgroundId,
principalTable: "CustomData",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Phones_PhoneModels_ModelId",
column: x => x.ModelId,
principalTable: "PhoneModels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExtensionUser",
columns: table => new
{
ExtensionsId = table.Column<int>(type: "integer", nullable: false),
OwnersId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExtensionUser", x => new { x.ExtensionsId, x.OwnersId });
table.ForeignKey(
name: "FK_ExtensionUser_Extensions_ExtensionsId",
column: x => x.ExtensionsId,
principalTable: "Extensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExtensionUser_Users_OwnersId",
column: x => x.OwnersId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExtensionPhone",
columns: table => new
{
ExtensionsId = table.Column<int>(type: "integer", nullable: false),
PhonesId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExtensionPhone", x => new { x.ExtensionsId, x.PhonesId });
table.ForeignKey(
name: "FK_ExtensionPhone_Extensions_ExtensionsId",
column: x => x.ExtensionsId,
principalTable: "Extensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExtensionPhone_Phones_PhonesId",
column: x => x.PhonesId,
principalTable: "Phones",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PhoneUser",
columns: table => new
{
OwnersId = table.Column<string>(type: "text", nullable: false),
PhonesId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PhoneUser", x => new { x.OwnersId, x.PhonesId });
table.ForeignKey(
name: "FK_PhoneUser_Phones_PhonesId",
column: x => x.PhonesId,
principalTable: "Phones",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PhoneUser_Users_OwnersId",
column: x => x.OwnersId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "PhoneModels",
columns: new[] { "Id", "MaxExtensions", "ModelName", "PreVvxPolycom" },
values: new object[] { 0, 6L, "Polycom VVX300/310", false });
migrationBuilder.CreateIndex(
name: "IX_CustomData_PhoneId",
table: "CustomData",
column: "PhoneId");
migrationBuilder.CreateIndex(
name: "IX_ExtensionPhone_PhonesId",
table: "ExtensionPhone",
column: "PhonesId");
migrationBuilder.CreateIndex(
name: "IX_Extensions_HoldMusicId",
table: "Extensions",
column: "HoldMusicId");
migrationBuilder.CreateIndex(
name: "IX_ExtensionUser_OwnersId",
table: "ExtensionUser",
column: "OwnersId");
migrationBuilder.CreateIndex(
name: "IX_Phones_BackgroundId",
table: "Phones",
column: "BackgroundId");
migrationBuilder.CreateIndex(
name: "IX_Phones_ModelId",
table: "Phones",
column: "ModelId");
migrationBuilder.CreateIndex(
name: "IX_PhoneUser_PhonesId",
table: "PhoneUser",
column: "PhonesId");
migrationBuilder.AddForeignKey(
name: "FK_CustomData_Phones_PhoneId",
table: "CustomData",
column: "PhoneId",
principalTable: "Phones",
principalColumn: "Id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CustomData_Phones_PhoneId",
table: "CustomData");
migrationBuilder.DropTable(
name: "ExtensionPhone");
migrationBuilder.DropTable(
name: "ExtensionUser");
migrationBuilder.DropTable(
name: "PhoneUser");
migrationBuilder.DropTable(
name: "Role");
migrationBuilder.DropTable(
name: "Extensions");
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropTable(
name: "Phones");
migrationBuilder.DropTable(
name: "CustomData");
migrationBuilder.DropTable(
name: "PhoneModels");
}
}
}

View file

@ -0,0 +1,356 @@
// <auto-generated />
using System;
using System.Net.NetworkInformation;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using PhoneToolMX.Data;
#nullable disable
namespace PhoneToolMX.Models.Migrations
{
[DbContext(typeof(PTMXContext))]
partial class PTMXContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.16")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ExtensionPhone", b =>
{
b.Property<int>("ExtensionsId")
.HasColumnType("integer");
b.Property<int>("PhonesId")
.HasColumnType("integer");
b.HasKey("ExtensionsId", "PhonesId");
b.HasIndex("PhonesId");
b.ToTable("ExtensionPhone", (string)null);
});
modelBuilder.Entity("ExtensionUser", b =>
{
b.Property<int>("ExtensionsId")
.HasColumnType("integer");
b.Property<string>("OwnersId")
.HasColumnType("text");
b.HasKey("ExtensionsId", "OwnersId");
b.HasIndex("OwnersId");
b.ToTable("ExtensionUser", (string)null);
});
modelBuilder.Entity("PhoneToolMX.Models.CustomData", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Data")
.HasColumnType("bytea");
b.Property<int>("DataType")
.HasColumnType("integer");
b.Property<string>("FriendlyName")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<int?>("PhoneId")
.HasColumnType("integer");
b.Property<long>("Size")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("PhoneId");
b.ToTable("CustomData", (string)null);
});
modelBuilder.Entity("PhoneToolMX.Models.Extension", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<string>("DirectoryName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ExtId")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("integer")
.HasComputedColumnSql("\"Id\" + 1000", true);
b.Property<int?>("HoldMusicId")
.HasColumnType("integer");
b.Property<bool>("Listed")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("HoldMusicId");
b.ToTable("Extensions", (string)null);
});
modelBuilder.Entity("PhoneToolMX.Models.Phone", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<int?>("BackgroundId")
.HasColumnType("integer");
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("text");
b.Property<PhysicalAddress>("MacAddress")
.IsRequired()
.HasColumnType("macaddr");
b.Property<int>("ModelId")
.HasColumnType("integer");
b.Property<string>("Password")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValueSql("encode(gen_random_bytes(18), 'base64')");
b.HasKey("Id");
b.HasIndex("BackgroundId");
b.HasIndex("ModelId");
b.ToTable("Phones", (string)null);
});
modelBuilder.Entity("PhoneToolMX.Models.PhoneModel", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int?>("Id"));
b.Property<long>("MaxExtensions")
.HasColumnType("bigint");
b.Property<string>("ModelName")
.HasColumnType("text");
b.Property<bool>("PreVvxPolycom")
.HasColumnType("boolean");
b.HasKey("Id");
b.ToTable("PhoneModels", (string)null);
b.HasData(
new
{
Id = 0,
MaxExtensions = 6L,
ModelName = "Polycom VVX300/310",
PreVvxPolycom = false
});
});
modelBuilder.Entity("PhoneToolMX.Models.Role", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NormalizedName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Role", (string)null);
});
modelBuilder.Entity("PhoneToolMX.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasColumnType("text");
b.Property<string>("NormalizedUserName")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("PhoneUser", b =>
{
b.Property<string>("OwnersId")
.HasColumnType("text");
b.Property<int>("PhonesId")
.HasColumnType("integer");
b.HasKey("OwnersId", "PhonesId");
b.HasIndex("PhonesId");
b.ToTable("PhoneUser", (string)null);
});
modelBuilder.Entity("ExtensionPhone", b =>
{
b.HasOne("PhoneToolMX.Models.Extension", null)
.WithMany()
.HasForeignKey("ExtensionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PhoneToolMX.Models.Phone", null)
.WithMany()
.HasForeignKey("PhonesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ExtensionUser", b =>
{
b.HasOne("PhoneToolMX.Models.Extension", null)
.WithMany()
.HasForeignKey("ExtensionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PhoneToolMX.Models.User", null)
.WithMany()
.HasForeignKey("OwnersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("PhoneToolMX.Models.CustomData", b =>
{
b.HasOne("PhoneToolMX.Models.Phone", null)
.WithMany("Ringtones")
.HasForeignKey("PhoneId");
});
modelBuilder.Entity("PhoneToolMX.Models.Extension", b =>
{
b.HasOne("PhoneToolMX.Models.CustomData", "HoldMusic")
.WithMany()
.HasForeignKey("HoldMusicId");
b.Navigation("HoldMusic");
});
modelBuilder.Entity("PhoneToolMX.Models.Phone", b =>
{
b.HasOne("PhoneToolMX.Models.CustomData", "Background")
.WithMany()
.HasForeignKey("BackgroundId");
b.HasOne("PhoneToolMX.Models.PhoneModel", "Model")
.WithMany()
.HasForeignKey("ModelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Background");
b.Navigation("Model");
});
modelBuilder.Entity("PhoneUser", b =>
{
b.HasOne("PhoneToolMX.Models.User", null)
.WithMany()
.HasForeignKey("OwnersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PhoneToolMX.Models.Phone", null)
.WithMany()
.HasForeignKey("PhonesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("PhoneToolMX.Models.Phone", b =>
{
b.Navigation("Ringtones");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,21 @@
using PhoneToolMX.Models;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PhoneToolMX.Models {
public enum CustomDataType {
Background,
MusicTone,
}
public class CustomData : IModel {
public int? Id { get; set; }
[MaxLength(16)]
[Header("Name")]
public string FriendlyName { get; set; }
public CustomDataType DataType { get; set; }
[Header("Size")]
public uint Size { get; set; }
public byte[] Data { get; set; }
}
}

View file

@ -0,0 +1,8 @@
namespace PhoneToolMX.Models;
public class ErrorViewModel
{
public string RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
}

View file

@ -0,0 +1,25 @@
using PhoneToolMX.Data;
using PhoneToolMX.Models;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PhoneToolMX.Models {
public class Extension: OwnedBase {
public ICollection<Phone> Phones { get; set; }
[Header("Ext.", Primary = true, Small = true)]
public int ExtId { get; set; }
[Header("Directory Name")]
[Required]
public string DirectoryName { get; set; }
[Header("Listed?", Small = true)]
public bool Listed { get; set; }
public CustomData HoldMusic { get; set; }
[NotMapped]
public string ListViewName => $"{DirectoryName} ({ExtId})";
}
}

View file

@ -0,0 +1,19 @@
using System.Reflection;
namespace PhoneToolMX.Models
{
public interface IModel
{
public int? Id { get; set; }
public void Commit(IModel obj)
{
if (GetType() != obj.GetType()) throw new ArgumentException("Input object must be the same model");
foreach (var prop in GetType().GetProperties().Where(p => p.CanWrite)) {
if (prop.GetValue(obj, null) is {} propVal) {
prop.SetValue(this, propVal, null);
}
}
}
}
}

View file

@ -0,0 +1,10 @@
using PhoneToolMX.Models.ViewModels;
namespace PhoneToolMX.Models
{
public interface IOwnedModel : IModel
{
public ICollection<User> Owners { get; set; }
public bool IsOwnedBy(User user);
}
}

View file

@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore.Metadata;
using PhoneToolMX.Models.ViewModels;
using System.ComponentModel.DataAnnotations;
namespace PhoneToolMX.Models
{
public class OwnedBase : IOwnedModel
{
public int? Id { get; set; }
public ICollection<User> Owners { get; set; }
public bool IsOwnedBy(User user)
{
return (Owners != null && Owners.Any(o => o.Id == user.Id));
}
}
}

View file

@ -0,0 +1,28 @@
using PhoneToolMX.Data;
using PhoneToolMX.Models;
using PhoneToolMX.Models.ViewModels;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Net.NetworkInformation;
using System.Security.Cryptography;
namespace PhoneToolMX.Models {
public class Phone: OwnedBase {
[Required]
public PhysicalAddress MacAddress { get; set; }
[Header("Name", Primary = true)]
[Required]
public string FriendlyName { get; set; }
[AlwaysInclude]
[Required]
public PhoneModel Model { get; set; }
[AlwaysInclude]
public ICollection<Extension> Extensions { get; set; }
public CustomData Background { get; set; }
public ICollection<CustomData> Ringtones { get; set; }
public string Password { get; set; }
}
}

View file

@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace PhoneToolMX.Models
{
public class PhoneModel
{
public int? Id { get; set; }
/// <summary>
/// The friendly name of the phone (e.g., "Polycom VVX300/310")
/// </summary>
public string ModelName { get; set; }
/// <summary>
/// The maximum lines the phone can support.
/// </summary>
public uint MaxExtensions { get; set; }
/// <summary>
/// This setting impacts whether the model is pre-VVX era.
/// All Polycom phones that can be provisioned by Polycom UC
/// before the VVX era (mainly, SPIP phones) have a limit of
/// 300KiB per ringtone.
///
/// All Polycom phones, VVX or otherwise, have a limit of
/// 600KiB total across all ringtones.
/// </summary>
public bool PreVvxPolycom { get; set; }
}
}

View file

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations.Schema;
namespace PhoneToolMX.Models
{
public class Role : IdentityRole<string>
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public override string Id { get; set; }
public Role() { }
public Role(string name)
{
Name = name;
}
}
}

View file

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PhoneToolMX.Models {
public class User : IdentityUser<string>
{
public ICollection<Phone> Phones;
public ICollection<Extension> Extensions;
}
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="6.0.23" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="6.0.23" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.23">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.22" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,57 @@
using PhoneToolMX.Data;
using System.ComponentModel.DataAnnotations;
namespace PhoneToolMX.Models.ViewModels
{
public class ExtensionVM: IViewModel
{
public int? Id { get; set; }
[Header("Ext.", Primary = true, Small = true)]
public int ExtId { get; set; }
[Header("Directory Name")]
[Required]
[MaxLength(15)]
public string DirectoryName { get; set; }
[Header("Listed?", Small = true)]
[Required]
public bool Listed { get; set; }
public int? HoldMusic { get; set; }
public IOwnedModel ToEntity(PTMXContext ctx)
{
return new Extension
{
Id = Id,
ExtId = ExtId,
DirectoryName = DirectoryName,
Listed = Listed,
HoldMusic = HoldMusic == null ? null : ctx.CustomData.FirstOrDefault(c => c.Id == HoldMusic),
Owners = new List<User>(),
};
}
public IOwnedModel ToEntity(PTMXContext ctx, IOwnedModel current)
{
var ent = ToEntity(ctx);
ent.Owners = current.Owners;
return ent;
}
public IViewModel FromEntity(IOwnedModel entity)
{
if (entity is not Extension extEnt) throw new ArgumentException("entity must be of type Extension");
return new ExtensionVM
{
Id = extEnt.Id,
ExtId = extEnt.ExtId,
DirectoryName = extEnt.DirectoryName,
Listed = extEnt.Listed,
HoldMusic = extEnt.HoldMusic?.Id,
};
}
}
}

View file

@ -0,0 +1,15 @@
using PhoneToolMX.Data;
using PhoneToolMX.Models;
namespace PhoneToolMX.Models.ViewModels
{
public interface IViewModel
{
public int? Id { get; set; }
public IOwnedModel ToEntity(PTMXContext ctx);
public IOwnedModel ToEntity(PTMXContext ctx, IOwnedModel current);
public IViewModel FromEntity(IOwnedModel entity);
}
}

View file

@ -0,0 +1,64 @@
using PhoneToolMX.Data;
using PhoneToolMX.Models;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Net.NetworkInformation;
namespace PhoneToolMX.Models.ViewModels
{
[CheckExtensions]
public class PhoneVM : IViewModel
{
public int? Id { get; set; }
[Header("MAC Address")]
[Required]
[RegularExpression("(?:[0-9a-fA-F]{2}[-:]?){6}", ErrorMessage = "Must be of the form XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, or XXXXXXXXXXXX")]
public string MacAddress { get; set; }
[Header("Name", Primary = true)]
[Required]
public string FriendlyName { get; set; }
[Required]
public int? Model { get; set; }
public ICollection<int?> Extensions { get; set; }
public int MaxExtensions { get; set; }
public IOwnedModel ToEntity(PTMXContext ctx)
{
return new Phone
{
Id = Id,
MacAddress = PhysicalAddress.Parse(MacAddress),
FriendlyName = FriendlyName,
Model = ctx.PhoneModels.FirstOrDefault(p => p.Id == Model)!,
Extensions = Extensions?.Select(x => ctx.Extensions.FirstOrDefault(e => e.Id == x)).ToList(),
Owners = new List<User>(),
};
}
public IOwnedModel ToEntity(PTMXContext ctx, IOwnedModel current)
{
var ent = ToEntity(ctx);
ent.Owners = current.Owners;
return ent;
}
public IViewModel FromEntity(IOwnedModel entity)
{
if (entity is not Phone phoneEnt) throw new ArgumentException("entity must be of type Phone");
return new PhoneVM
{
Id = entity.Id,
MacAddress = phoneEnt.MacAddress.ToString(),
FriendlyName = phoneEnt.FriendlyName,
Model = phoneEnt.Model!.Id,
Extensions = phoneEnt.Extensions?.Select(x => x.Id).ToList(),
MaxExtensions = (int)phoneEnt.Model!.MaxExtensions,
};
}
}
}

36
PhoneToolMX/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,36 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/bin/Debug/net6.0/PhoneToolMX.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5001"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

41
PhoneToolMX/.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PhoneToolMX.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/PhoneToolMX.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/PhoneToolMX.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

View file

@ -0,0 +1,225 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PhoneToolMX.Data;
using PhoneToolMX.Helpers;
using PhoneToolMX.Models;
using PhoneToolMX.Models.ViewModels;
using System.Security.Authentication;
using System.Security.Claims;
namespace PhoneToolMX.Controllers
{
[Authorize]
[Route("[controller]")]
public abstract class BaseController<T, TViewModel> : Controller
where T: OwnedBase, IModel
where TViewModel : IViewModel, new()
{
protected private readonly PTMXContext _context;
private readonly UserManager<User> _userManager;
protected BaseController(UserManager<User> mgr, PTMXContext ctx)
{
_context = ctx;
_userManager = mgr;
}
protected private virtual Task PreForm(TViewModel vm)
{
throw new NotImplementedException();
}
#region Helper methods
/// <summary>
/// Gets the "friendly" name for an owned model object.
/// </summary>
/// <param name="vm">The model object to inspect</param>
/// <returns>The friendly name, or its ID if none was defined</returns>
private static string GetFriendlyName(IViewModel vm)
{
if (vm is not TViewModel model) return vm.Id.ToString();
return typeof(TViewModel).GetProperties().Where(pi =>
{
var headers = pi.GetCustomAttributes(typeof(HeaderAttribute), false);
return headers.Length != 0 && ((HeaderAttribute)headers[0]).Primary;
}).Select(pi => pi.GetValue(model)?.ToString()).FirstOrDefault() ?? model.Id.ToString();
}
private static string GetFriendlyName(IOwnedModel model)
{
var vm = new TViewModel().FromEntity(model);
return GetFriendlyName(vm);
}
protected private async Task<User> CurrentUser()
{
var claim = HttpContext.User.Claims.FirstOrDefault(c => c.Type.Equals(ClaimTypes.NameIdentifier));
if (claim == null) {
throw new InvalidCredentialException("fuck this noise, nameid claim missing");
}
var user = await _userManager.FindByIdAsync(claim.Value);
return user;
}
private async Task<IActionResult> FormView(TViewModel vm)
{
await PreForm(vm);
return View("_Form", vm);
}
private void SetMessage(FormMessage msg)
{
TempData["Message"] = msg.ToString();
}
private void SetMessage(FormMessageType type, string msg)
{
SetMessage(new FormMessage
{
Type = type,
Message = msg,
});
}
private async Task<IActionResult> ValidationError(TViewModel vm)
{
var plural = ModelState.ErrorCount > 1 ? "s" : string.Empty;
SetMessage(
FormMessageType.Error,
$"Error{plural} occurred in validation. Make the changes required and click Submit to try again."
);
return await FormView(vm);
}
#endregion
#region Create
[HttpGet("New")]
public async Task<IActionResult> New()
{
ViewData["Title"] = $"Add new {typeof(T).Name.ToLower()}";
return await FormView(default);
}
[HttpPost("New")]
public async Task<IActionResult> NewPost(TViewModel vm)
{
ViewData["Title"] = $"New {typeof(T).Name}";
if (!ModelState.IsValid) return await ValidationError(vm);
if (vm.ToEntity(_context) is not T model) throw new InvalidOperationException($"{typeof(TViewModel).FullName}.ToEntity() somehow produced something other than a {typeof(T).FullName}");
var entity = await _context.AddOwnable(await CurrentUser(), model);
await _context.SaveChangesAsync();
SetMessage(
FormMessageType.Success,
$"{typeof(T).Name} {GetFriendlyName(vm)} was created."
);
return RedirectToAction("Edit", new { id = entity.Entity.Id });
}
#endregion
#region Read
[HttpGet]
public async Task<IActionResult> Index()
{
ViewData["Title"] = $"My {typeof(T).Name}s";
return View("Index", _context
.GetOwned<T>(await CurrentUser())
.Select(m => (TViewModel)new TViewModel().FromEntity(m))
.ToList());
}
#endregion
#region Update
[HttpGet("Edit")]
public async Task<IActionResult> Edit(int id)
{
var model = _context.GetOwned<T>(await CurrentUser()).FirstOrDefault(o => o.Id == id);
if (model == null) return NotFound();
ViewData["Title"] = $"Editing {typeof(T).Name.ToLower()} {GetFriendlyName(new TViewModel().FromEntity(model))}";
return await FormView((TViewModel)new TViewModel().FromEntity(model));
}
[HttpPost("Edit")]
public async Task<IActionResult> EditPost(TViewModel vm)
{
if (vm?.Id == null) {
return BadRequest();
}
if (!ModelState.IsValid) return await ValidationError(vm);
// merge VM's changes with DbModel
var currentModel = _context.GetEntityById<T>(vm.Id);
if (vm.ToEntity(_context, currentModel) is not T entity) throw new InvalidOperationException($"{typeof(TViewModel).FullName}.ToEntity() somehow produced something other than a {typeof(T).FullName}");
currentModel.Commit(entity);
// and commit back
_context.Set<T>().Update(currentModel);
await _context.SaveChangesAsync();
SetMessage(
FormMessageType.Success,
$"{typeof(T).Name} {GetFriendlyName(vm)} was updated."
);
return RedirectToAction("Edit", new { id = entity.Id });
}
#endregion
#region Delete
[HttpGet("Delete")]
public async Task<IActionResult> Delete(int id)
{
var model = _context.GetEntityById<T>(id);
if (model?.IsOwnedBy(await CurrentUser()) != true) {
return NotFound();
}
ViewData["Title"] = $"Delete {GetFriendlyName(model)}";
TempData["ModelDelete"] = model.Id;
return View("DeleteConfirm", model);
}
[HttpPost("Delete")]
public async Task<IActionResult> PostDelete(int id)
{
if ((int?)TempData["ModelDelete"] != id) {
return BadRequest();
}
var model = _context.GetEntityById<T>(id);
if (model?.IsOwnedBy(await CurrentUser()) != true) {
return NotFound();
}
_context.Set<T>().Remove(model);
await _context.SaveChangesAsync();
SetMessage(
FormMessageType.Success,
$"{typeof(T).Name} {GetFriendlyName(new TViewModel().FromEntity(model))} was successfully deleted."
);
return RedirectToAction("Index");
}
#endregion
}
}

View file

@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PhoneToolMX.Models;
using PhoneToolMX.Data;
using PhoneToolMX.Helpers;
using PhoneToolMX.Models.ViewModels;
namespace PhoneToolMX.Controllers {
[Authorize]
public class ExtensionController : BaseController<Extension, ExtensionVM>
{
public ExtensionController(UserManager<User> mgr, PTMXContext ctx) : base(mgr, ctx) {}
protected override private Task PreForm(ExtensionVM e)
{
var holdMusics = _context.CustomData.Where(d => d.DataType == CustomDataType.MusicTone).ToList();
ViewBag.HoldMusics = holdMusics;
ViewBag.SelectedMusic = e?.HoldMusic == null ? null : holdMusics.FirstOrDefault(d => d.Id == e.HoldMusic);
return Task.CompletedTask;
}
}
}

View file

@ -0,0 +1,27 @@
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using PhoneToolMX.Models;
namespace PhoneToolMX.Controllers;
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
public HomeController(ILogger<HomeController> logger)
{
_logger = logger;
}
public IActionResult Index()
{
return View();
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}

View file

@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc;
namespace PhoneToolMX.Controllers
{
public class ModelController : Controller
{
// GET
public IActionResult Index()
{
return View();
}
}
}

View file

@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PhoneToolMX.Data;
using PhoneToolMX.Helpers;
using PhoneToolMX.Models;
using PhoneToolMX.Models.ViewModels;
namespace PhoneToolMX.Controllers
{
[Authorize]
public class PhoneController : BaseController<Phone, PhoneVM>
{
public PhoneController(UserManager<User> mgr, PTMXContext ctx) : base(mgr, ctx) {}
protected override private async Task PreForm(PhoneVM pvm)
{
var myExts = _context.GetOwned<Extension>(await CurrentUser());
var phoneModels = _context.PhoneModels.ToList();
ViewBag.MyExtensions = myExts;
ViewBag.SelectedExtensions = pvm?.Extensions == null ? null : myExts.Where(x => pvm.Extensions.Contains(x.Id)).ToList();
ViewBag.ModelNumbers = phoneModels;
ViewBag.CurrentModel = pvm?.Model == null ? null : phoneModels.Where(m => m.Id == pvm.Model);
}
}
}

View file

@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=aors/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -0,0 +1,18 @@
using System.Text.Json;
namespace PhoneToolMX.Helpers {
public enum FormMessageType {
Success,
Info,
Warning,
Error,
}
public class FormMessage {
public FormMessageType Type { get; set; }
public string Message { get; set; }
public string CssClassName() => $"msg-{Type.ToString().ToLower()}";
public override string ToString() => JsonSerializer.Serialize(this);
}
}

View file

@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using PhoneToolMX.Models;
using System.Linq.Expressions;
using Microsoft.AspNetCore.Mvc;
namespace PhoneToolMX.Helpers
{
public static class HtmlTable
{
/// <summary>
/// Creates an HTML table for a given collection of model objects.
/// </summary>
/// <param name="helper">HtmlHelper instance in the view</param>
/// <param name="headers">List of table headers</param>
/// <param name="rowFunc">Function used to determine the structure of each row. All objects will be converted to strings with ToString().</param>
/// <typeparam name="TModel"></typeparam>
/// <returns>An HTML table representing the list.</returns>
/// <remarks>
/// https://xkcd.com/1319/
/// </remarks>
public static IHtmlContent MakeTable<TModel>(this IHtmlHelper<ICollection<TModel>> helper)
{
var builder = new HtmlContentBuilder();
var urlFactory = helper.ViewContext.HttpContext.RequestServices.GetRequiredService<IUrlHelperFactory>();
var url = urlFactory.GetUrlHelper(helper.ViewContext);
var props = typeof(TModel).GetProperties().Where(pi => pi.GetCustomAttributes(typeof(HeaderAttribute), false).Length != 0).ToList();
var idProp = typeof(TModel).GetProperty("Id");
var headerList = props.Select(pi =>
{
var attr = (HeaderAttribute)pi.GetCustomAttributes(typeof(HeaderAttribute), false)[0];
return attr;
}).ToList();
headerList.Add(new HeaderAttribute
{
Title = "Actions",
Small = true,
});
builder.AppendHtmlLine("<table id=\"listview\">")
.AppendHtmlLine("<thead><tr>");
foreach (var header in headerList) {
builder.AppendFormat("<th{0}>{1}</th>",
(header.Small) ? " width=2" : string.Empty,
header.Title);
}
builder.AppendHtmlLine("</tr></thead>")
.AppendHtmlLine("<tbody>");
// build body
if (helper.ViewData.Model is {} rows) {
foreach (var row in rows) {
var cells = props
.Select(pi =>
{
var val = pi.GetValue(row, null);
if (val is bool someBool) {
// idk, yes/no feels better to me than true/false for user-facing stuff
return someBool ? "Yes" : "No";
}
return val?.ToString() ?? "(null)";
})
.ToList();
var cellId = idProp?.GetValue(row, null)?.ToString() ?? "INVALID_ID";
builder.AppendHtmlLine("<tr>");
foreach (var cell in cells) {
builder.AppendFormat("<td>{0}</td>", cell);
}
// Actions cell
builder.AppendHtml("<td align=\"right\">")
// ReSharper disable Mvc.ActionNotResolved
.AppendHtml($"<a href=\"{url.Action("Edit", new {id = cellId})}\"><img src=\"{url.Content("~/images/edit_x16.gif")}\" alt=\"Edit\" /></a>")
.AppendHtml($"<a href=\"{url.Action("Delete", new {id = cellId})}\"><img src=\"{url.Content("~/images/del_x16.gif")}\" alt=\"Delete\" /></a>")
.AppendHtmlLine("</td>")
.AppendHtmlLine("</tr>");
}
if (rows.Count == 0) {
builder.AppendFormat("<tr><td colspan=\"{0}\" align=\"center\">No entries found</td></tr>", headerList.Count);
}
} else {
builder.AppendFormat("<tr><td colspan=\"{0}\" align=\"center\">Invalid model found</td></tr>", headerList.Count);
}
// close remaining table tags
return builder.AppendHtmlLine("</tbody>")
.AppendHtmlLine("</table>");
}
}
}

View file

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.23" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="6.0.23" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.23" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="6.0.23" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.23">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="6.0.23" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="6.0.13" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.22" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PhoneToolMX.Models\PhoneToolMX.Models.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Authorization\" />
<Folder Include="ViewModels\" />
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="wwwroot\js\site.js" />
</ItemGroup>
</Project>

131
PhoneToolMX/Program.cs Normal file
View file

@ -0,0 +1,131 @@
using PhoneToolMX.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using PhoneToolMX.Models;
using System.Security.Authentication;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<PTMXContext>(
options => options.UseNpgsql(builder.Configuration.GetConnectionString("DbConnection"),
b => b.MigrationsAssembly("PhoneToolMX.Models")));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddIdentityCore<User>(opts =>
{
opts.ClaimsIdentity.UserIdClaimType = "sub";
opts.ClaimsIdentity.UserNameClaimType = "preferred_username";
opts.ClaimsIdentity.EmailClaimType = "email";
})
.AddRoles<Role>()
.AddRoleManager<RoleManager<Role>>()
.AddSignInManager<SignInManager<User>>()
.AddUserManager<UserManager<User>>()
.AddEntityFrameworkStores<PTMXContext>();
// Using OIDC
builder.Services.AddAuthentication(opts =>
{
opts.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
opts.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect((OpenIdConnectOptions opts) => {
// pull from appsettings
var oidcConfig = builder.Configuration.GetRequiredSection("OpenIdConnect");
opts.ClientId = oidcConfig.GetValue<string>("ClientId");
opts.ClientSecret = oidcConfig.GetValue<string>("ClientSecret");
opts.MetadataAddress = oidcConfig.GetValue<string>("MetadataUrl");
opts.Authority = oidcConfig.GetValue<string>("Authority");
opts.GetClaimsFromUserInfoEndpoint = true;
opts.SaveTokens = false;
opts.ResponseType = "code";
opts.Scope.Add("openid");
opts.Events = new OpenIdConnectEvents
{
OnUserInformationReceived = async ctx =>
{
// Create the user in UserManager for future authz
var _userManager = ctx.HttpContext.RequestServices.GetRequiredService<UserManager<User>>();
var curUser = ctx.Principal;
var user = new User
{
Id = curUser?.Claims.FirstOrDefault(c => c.Type.Equals(ClaimTypes.NameIdentifier))
?.Value,
UserName = curUser?.Claims.FirstOrDefault(c => c.Type.Equals("preferred_username"))?.Value,
Email = curUser?.Claims.FirstOrDefault(c => c.Type.Equals("email"))
?.Value,
EmailConfirmed = true, // always confirmed (or rather, expect the IdP to confirm it)
Phones = null, // no phones or extensions by default
Extensions = null,
};
if (user.Id == null) {
throw new InvalidCredentialException($"Missing required OIDC claim \"{ClaimTypes.NameIdentifier}\"");
}
if (await _userManager.FindByIdAsync(user.Id) == null) {
var res = await _userManager.CreateAsync(user);
if (res.Succeeded == false) {
throw new InvalidCredentialException($"Creating identity failed: {res.Errors.FirstOrDefault()!.Description}");
}
}
}
};
// if dev, disable secure
if (!builder.Environment.IsDevelopment()) return;
opts.NonceCookie.SecurePolicy = CookieSecurePolicy.None;
opts.NonceCookie.SameSite = SameSiteMode.Unspecified;
opts.CorrelationCookie.SecurePolicy = CookieSecurePolicy.None;
opts.CorrelationCookie.SameSite = SameSiteMode.Unspecified;
});
builder.Services.AddAuthorization(opts =>
{
opts.AddPolicy("RequireAdmin",
policy => policy.RequireRole("Administrator"));
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
} else {
app.UseDeveloperExceptionPage();
app.UseMigrationsEndPoint();
}
using (var scope = app.Services.CreateScope()) {
var services = scope.ServiceProvider;
var context = services.GetRequiredService<PTMXContext>();
context.Database.EnsureCreated();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}")
.RequireAuthorization();
app.Run();

View file

@ -0,0 +1,28 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:5205",
"sslPort": 44330
}
},
"profiles": {
"PhoneToolMX": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": false,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,10 @@
@model PhoneToolMX.Models.ViewModels.ExtensionVM
@using (Html.BeginForm(FormMethod.Post)) {
<p>Are you sure you want to delete extension @(Model?.ExtId)?</p>
<div align="center">
<input type="hidden" name="id" value="@Model?.Id"/>
<input type="submit" value="Yes, Delete"/>
</div>
}

View file

@ -0,0 +1,6 @@
@using PhoneToolMX.Helpers
@model ICollection<PhoneToolMX.Models.ViewModels.ExtensionVM>
<p>This is a list of all extensions you can edit.</p>
@Html.MakeTable()

View file

@ -0,0 +1,33 @@
@model PhoneToolMX.Models.ViewModels.ExtensionVM
<!--suppress HtmlDeprecatedAttribute -->
<p>Fill out the fields below, then click "Submit" to @ViewData["Action"] the line.</p>
@using (Html.BeginForm(FormMethod.Post)) {
<table cellpadding="2" cellspacing="2" border="0" width="100%">
@if (Model != null) {
@Html.HiddenFor(m => m.Id)
}
<tr>
<th>@Html.LabelFor(m => m.DirectoryName, "Name in Directory:")</th>
<td>@Html.TextBoxFor(m => m.DirectoryName)@Html.ValidationMessageFor(m => m.DirectoryName)</td>
</tr>
<tr>
<th>@Html.LabelFor(m => m.HoldMusic, "Hold Music:")</th>
<td>@if (ViewBag.HoldMusics == null || ((ICollection<CustomData>)ViewBag.HoldMusics).Count == 0) {
@Html.DropDownListFor(m => m.HoldMusic, new List<SelectListItem>(), "-- No music available --", new { @disabled=true })
} else {
@Html.DropDownListFor(m => m.HoldMusic, new SelectList(ViewBag.HoldMusics, "Id", "FriendlyName"))
}</td>
</tr>
<tr>
<td colspan="2">@Html.CheckBoxFor(m => m.Listed) @Html.LabelFor(m => m.Listed, "Show extension in global directory")</td>
</tr>
<tr>
<td colspan="2" align="right">
<input type="reset" value="Reset" />
<input type="submit" value="Submit" />
</td>
</tr>
</table>
}

View file

@ -0,0 +1,24 @@
@{
ViewData["Title"] = "Home Page";
}
<p>Welcome to PhoneToolMX! Select one of the options from the sidebar to begin.</p>
<hr />
<h2>News</h2>
<h3>Oct 17, 2023 :: Version 1.0</h3>
<p>Finally, initial release! As far as user-facing features go, it's still pretty light.
But the scaffolding is there for new features like:</p>
<ul>
<li>Online Voicemail</li>
<li>Custom Media (Wallpapers, Hold Music, Ringtones)</li>
<li>Call Groups (Multiple Extensions -> One Number)</li>
</ul>
<p>Even though this is v1.0, expect bugs, and please
<a href="https://git.2ki.xyz/flurry/PhoneToolMX/issues">report them if you see them</a>.</p>
@this.Url.Action("Error")

View file

@ -0,0 +1,6 @@
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View file

@ -0,0 +1,7 @@
@using PhoneToolMX.Helpers
@model ICollection<PhoneModel>
@{
ViewData["Title"] = "Phone Models";
}
@Html.MakeTable()

View file

@ -0,0 +1,13 @@
@model PhoneToolMX.Models.ViewModels.PhoneVM
@{
ViewData["Title"] = $"Deactivate {Model?.FriendlyName}";
}
@using (Html.BeginForm(FormMethod.Post)) {
<p>Are you sure you want to deactivate phone @(Model?.FriendlyName)? This will not delete any associated extensions.</p>
<div align="center">
<input type="hidden" name="id" value="@Model?.Id"/>
<input type="submit" value="Yes, Delete"/>
</div>
}

View file

@ -0,0 +1,6 @@
@using PhoneToolMX.Helpers
@model ICollection<PhoneToolMX.Models.ViewModels.PhoneVM>
<p>This is a list of all phones you manage.</p>
@Html.MakeTable()

View file

@ -0,0 +1,34 @@
@model PhoneToolMX.Models.ViewModels.PhoneVM
<!--suppress HtmlDeprecatedAttribute -->
<p>Fill out the fields below, then click "Submit" to @ViewData["Action"] the phone.</p>
@using (Html.BeginForm(FormMethod.Post)) {
<table cellpadding="2" cellspacing="2" border="0" width="100%">
@if (Model != null) {
@Html.HiddenFor(m => m.Id)
}
<tr>
<th>@Html.LabelFor(m => m.MacAddress, "MAC Address:")</th>
<td>@Html.TextBoxFor(m => m.MacAddress)@Html.ValidationMessageFor(m => m.MacAddress)</td>
</tr>
<tr>
<th>@Html.LabelFor(m => m.FriendlyName, "Phone Name:")</th>
<td>@Html.TextBoxFor(m => m.FriendlyName)@Html.ValidationMessageFor(m => m.FriendlyName)</td>
</tr>
<tr>
<th>@Html.LabelFor(m => m.Model, "Phone Model:")</th>
<td>@Html.DropDownListFor(m => m.Model, new SelectList(ViewBag.ModelNumbers, "Id", "ModelName", ViewBag.CurrentModel))@Html.ValidationMessageFor(m => m.Model)</td>
</tr>
<tr>
<th>@Html.LabelFor(m => m.Extensions, "Extensions:")</th>
<td>@Html.ListBoxFor(m => m.Extensions, new MultiSelectList(ViewBag.MyExtensions, "Id", "ListViewName", ViewBag.SelectedExtensions))
</tr>
<tr>
<td colspan="2" align="right">
<input type="reset" value="Reset" />
<input type="submit" value="Submit" />
</td>
</tr>
</table>
}

View file

@ -0,0 +1,14 @@
@model ErrorViewModel
@{
ViewData["Title"] = "Something went wrong!";
}
<p>An unknown error occurred while processing your request.</p>
@if (Model?.ShowRequestId == true) {
<p>Provide the following request ID to the application administrator, or <a href="https://git.2ki.xyz/flurry/PhoneToolMX/issues">include it in a bug report</a>:</p>
<ul>
<li>@Model.RequestId</li>
</ul>
}

View file

@ -0,0 +1,89 @@
@using PhoneToolMX.Helpers
@using System.Text.Json
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">
@{
var userIdent = (System.Security.Claims.ClaimsIdentity)User.Identity;
}
<html lang="en">
@* ReSharper disable once MissingTitleTag *@
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
<link rel="stylesheet" href="/main.css" />
<title>@ViewData["Title"] - PhoneToolMX</title>
</head>
<body>
<table id="main" cellspacing="0" cellpadding="2">
<!-- header img -->
<tr>
<td id="header" colspan="3">
<img alt="Website banner" src="/images/banner.gif"/></td>
</tr>
<!-- nav sidebar -->
<tr>
<td rowspan="2" width="235" id="nav">
<h4 style="margin-top:0; margin-bottom:0;">Navigation</h4>
<hr size="1" width="100%"/>
<ul>
<li><a href="@Url.Action("Index", "Home")">Home</a></li>
<li>Extensions
<ul>
<li><a href="@Url.Action("Index", "Extension")">My Extensions</a></li>
<li><a href="@Url.Action("New", "Extension")">Create New Extension</a></li>
</ul>
</li>
<li>Phones
<ul>
<li><a href="@Url.Action("Index", "Phone")">My Phones</a></li>
<li><a href="@Url.Action("New", "Phone")">Provision New Phone</a></li>
</ul>
</li>
<li>Media
<ul>
<li><a href="#">Ringtones and Hold Music</a></li>
<li><a href="#">Wallpapers</a></li>
</ul>
</li>
</ul>
</td>
<!-- context header -->
<td width="237" height="10" id="context">
@await RenderSectionAsync("CtxOptions", false)
<!--
<form style="padding:0;margin:0;" action="" method="GET">
<label style="vertical-align:middle;" for="ext">TODO-- EDIT THIS PER VIEW:&nbsp;</label>
<select name="ext">
<option value="1001">1001</option></select> <input type="submit" value="&nbsp;&nbsp;Go&nbsp;&nbsp;"/><br>
</form>
-->
</td>
<td width="237" height="10" id="userinfo">
@if (userIdent != null) {
<span>Hello, @userIdent.Name!</span> @("[")<a href="#">Logout</a>@("]")
} else {
<a href="#">Login</a>
}
</td>
<!-- body text -->
<tr>
<td width="475" id="content" colspan="2">
<h2>@ViewData["Title"]</h2>
@if (TempData["Message"] != null) {
var msg = JsonSerializer.Deserialize<FormMessage>((string)TempData["Message"]);
<div class="status @msg!.CssClassName()">
@msg.Message
</div>
}
@RenderBody()
</td>
</tr>
<tr>
<td id="footer" align="center" colspan="4">
Best viewed on a display with 800x600 or higher resolution with <a href="#">Netscape Navigator</a>.
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,49 @@
/* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #0077cc;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid #e5e5e5;
}
.border-bottom {
border-bottom: 1px solid #e5e5e5;
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}

View file

@ -0,0 +1,3 @@
@using PhoneToolMX
@using PhoneToolMX.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View file

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View file

@ -0,0 +1,18 @@
{
"Urls": "http://localhost:5001",
"ConnectionStrings": {
"DbConnection": "Host=yuuka.i.2ki.xyz; Database=ptmx_dev; Username=flurry; Password=PeE4PKpMUE2qPja4FyGrvB8FVArZKWa5YReFbfRW649chx38"
},
"OpenIdConnect": {
"ClientId": "3f2b1348-6d6b-4332-8c43-0a7a5707ccbf",
"ClientSecret": "gto_ljwf22pbzaop7tv6nv2upf6binv7siybye7xu5ytecfrdlils6ra",
"MetadataUrl": "https://git.2ki.xyz/.well-known/openid-configuration",
"Authority": "https://git.2ki.xyz"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

View file

@ -0,0 +1,182 @@
body {
background-color:#333333;
}
table#main {
margin-left:auto;
margin-right:auto;
width:722px;
color:#000000;
font-family:Geneva, Arial, Helvetica, sans-serif;
font-size:12px;
border:none;
}
table#main td {
background-color:#CCCCCC;
padding:0.45em;
border: 1px solid #000000;
}
/*
table#main table td {
padding:unset;
border:none;
}
*/
form table input[type="text"],
form table select {
width:100%;
box-sizing: border-box;
-moz-box-sizing: border-box;
}
table#main td#header {
background-color:#ffffff;
height:150px;
padding: 0;
margin:0;
}
table#main td#header img {
width:720px;
height:150px;
padding:0;
display:block;
margin:1px;
}
table#main td#nav {
background-color:#ccccff;
}
table#main td#nav, table#main td#content {
vertical-align:top;
}
table#main td#nav ul {
list-style-type:square;
margin: 0.5em 0 0.5em 1em;
padding: 0;
}
table#main td#nav ul li {
margin:0 0 0.25em 0.5em;
padding: 0;
}
table#main td#footer {
background-color:#000000;
color:#ffffff;
font-size:10px;
}
table#main td#footer a:link {
color:#00CCFF;
}
table#main td#context,
table#main td#userinfo {
background-color:#000000;
color: #ffffff;
height:1.5em;
vertical-align:middle;
}
table#main td#userinfo a:link,
table#main td#userinfo a:visited,
table#main td#userinfo a:active,
table#main td#footer a:link,
table#main td#footer a:visited,
table#main td#footer a:active {
color: #ffffff;
}
table#main td#context form label,
table#main td#context form select,
table#main td#context form input {
display:table-cell;
vertical-align:middle;
}
table#main td#context form select,
table#main td#context form input {
height:1.5em;
font-size:1em;
padding:0;
}
table#main td#userinfo {
text-align:right;
}
table#main td#content {
padding:1em;
}
td#content h1,
td#content h2,
td#content h3 {
margin-top:0.25em;
margin-bottom:0.25em;
}
table#listview {
width: 100%;
border: 1px outset;
border-spacing: 1px;
background-color: #666;
}
table#listview td,
table#listview th {
padding: 0.25em;
border: 1px inset;
}
table#listview thead {
background-color: #aaa;
border: 1px #aaa inset;
}
table#main form table td {
border: unset;
}
div.status {
border: 2px ridge;
margin: 0.25em 0;
padding: 0.5em;
}
div.status.msg-success {
border-color: #4a4;
color: #161;
background-color: #bfb;
}
div.status.msg-warning {
border-color: #aa4;
color: #661;
background-color: #ffb;
}
div.status.msg-error {
border-color: #a44;
color: #611;
background-color: #fbb;
}
div.status.msg-info {
border-color: #444;
color: #111;
background-color: #bbb;
}
span.field-validation-error {
width: 100%;
display: block;
font-size: 10px;
color: #c00;
}

7
global.json Normal file
View file

@ -0,0 +1,7 @@
{
"sdk": {
"version": "6.0.0",
"rollForward": "latestMajor",
"allowPrerelease": true
}
}

28
ptmx-asp.sln Normal file
View file

@ -0,0 +1,28 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30114.105
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneToolMX", "PhoneToolMX\PhoneToolMX.csproj", "{130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhoneToolMX.Models", "PhoneToolMX.Models\PhoneToolMX.Models.csproj", "{84FB1CE2-A4B6-4251-9427-8AEB175D6874}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{130DD8E2-5E99-47A9-8B30-2FF54A0DA89D}.Release|Any CPU.Build.0 = Release|Any CPU
{84FB1CE2-A4B6-4251-9427-8AEB175D6874}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{84FB1CE2-A4B6-4251-9427-8AEB175D6874}.Debug|Any CPU.Build.0 = Debug|Any CPU
{84FB1CE2-A4B6-4251-9427-8AEB175D6874}.Release|Any CPU.ActiveCfg = Release|Any CPU
{84FB1CE2-A4B6-4251-9427-8AEB175D6874}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

3
ptmx-asp.sln.DotSettings Normal file
View file

@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=exts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=PTMX/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>