diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..4146007c311 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,48 @@ + +# Copilot Instructions – Nop.Plugin.Misc.SerialNumbers + +## Syfte +Denna plugin hanterar generering och administration av serienummer för produkter i NopCommerce. All kod följer strikt NopCommerce 4.60+ och .NET 9 best practice. Endast version 4.60+ ska användas – äldre versioner eller andra mönster får inte övervägas eller implementeras. + +## Struktur +- **Ingen Areas/Admin**: All adminfunktionalitet sker via controller, vy och komponenter enligt NopCommerce standard. +- **Konfigurationsmodell**: `SerialNumberSettingsModel` är ett `record` som ärver från `BaseNopModel`. +- **Validering och hjälptexter**: Alla properties har `[NopResourceDisplayName]` och relevanta valideringsattribut. +- **CSS**: All CSS är flyttad till extern fil (`Content/serial-numbers.css`). +- **Controller**: Async GET/POST för konfiguration, följer Dependency Injection och Clean Code. +- **Vy**: Razor-formulär med hjälptexter, validering och modern UI. +- **Testbarhet**: Kod är testbar och följer SOLID. + + +- Använd alltid async/await. +- Följ NopCommerce plugin-arkitektur. +- Endast NopCommerce 4.60+ får användas (äldre versioner, klasser eller patterns är ej tillåtna). +- Placera plugin i `Plugins`-mappen. +- Ingen inline CSS – använd extern fil. +- Alla konfigurationsvyer ska vara användarvänliga och ha hjälptexter. +- Validering med DataAnnotations. +- Ingen överflödig kod i modeller. +- Bygg med `dotnet build src/NopCommerce.sln`. +- Stöd för MS SQL, MySQL, PostgreSQL och Docker. + +- Använd aldrig regions (`#region`/`#endregion`) i kodbasen. + +## Källor +- [NopCommerce Docs](https://docs.nopcommerce.com/en/index.html) +- [NopCommerce GitHub](https://github.com/nopSolutions/nopCommerce) +- [Microsoft Docs: Records (C#)](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record) +- [NopCommerce Migration Guide](https://github.com/nopSolutions/nopCommerce/issues/6722) + +## Checklist +- [x] Areas/Admin borttaget +- [x] Konfigurationsmodell som record +- [x] Hjälptexter och validering +- [x] CSS i extern fil +- [x] Controller och vy enligt best practice +- [x] Testbar kod +- [x] Byggbar och synlig i admin + +## Vidare arbete +- Lägg till XML-kommentarer för properties om önskas. +- Granska controller, vy och teststruktur vid behov. +- Följ alltid senaste NopCommerce och .NET riktlinjer. diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 00000000000..0b9486e855f --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,29 @@ +name: Build and Test + +on: + push: + branches: [ "*" ] + pull_request: + branches: [ "*" ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + - name: Restore dependencies + run: dotnet restore src/NopCommerce.sln + - name: Build + run: dotnet build src/NopCommerce.sln --configuration Release --no-restore + - name: Test + run: dotnet test src/NopCommerce.sln --configuration Release --no-build --logger "trx;LogFileName=test-results.trx" + - name: Upload test results + uses: actions/upload-artifact@v4 + with: + name: test-results + path: '**/TestResults/*.trx' diff --git a/dotnet-install.sh b/dotnet-install.sh new file mode 100755 index 00000000000..034d2dfb104 --- /dev/null +++ b/dotnet-install.sh @@ -0,0 +1,1888 @@ +#!/usr/bin/env bash +# Copyright (c) .NET Foundation and contributors. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +# Stop script on NZEC +set -e +# Stop script if unbound variable found (use ${var:-} if intentional) +set -u +# By default cmd1 | cmd2 returns exit code of cmd2 regardless of cmd1 success +# This is causing it to fail +set -o pipefail + +# Use in the the functions: eval $invocation +invocation='say_verbose "Calling: ${yellow:-}${FUNCNAME[0]} ${green:-}$*${normal:-}"' + +# standard output may be used as a return value in the functions +# we need a way to write text on the screen in the functions so that +# it won't interfere with the return value. +# Exposing stream 3 as a pipe to standard output of the script itself +exec 3>&1 + +# Setup some colors to use. These need to work in fairly limited shells, like the Ubuntu Docker container where there are only 8 colors. +# See if stdout is a terminal +if [ -t 1 ] && command -v tput > /dev/null; then + # see if it supports colors + ncolors=$(tput colors || echo 0) + if [ -n "$ncolors" ] && [ $ncolors -ge 8 ]; then + bold="$(tput bold || echo)" + normal="$(tput sgr0 || echo)" + black="$(tput setaf 0 || echo)" + red="$(tput setaf 1 || echo)" + green="$(tput setaf 2 || echo)" + yellow="$(tput setaf 3 || echo)" + blue="$(tput setaf 4 || echo)" + magenta="$(tput setaf 5 || echo)" + cyan="$(tput setaf 6 || echo)" + white="$(tput setaf 7 || echo)" + fi +fi + +say_warning() { + printf "%b\n" "${yellow:-}dotnet_install: Warning: $1${normal:-}" >&3 +} + +say_err() { + printf "%b\n" "${red:-}dotnet_install: Error: $1${normal:-}" >&2 +} + +say() { + # using stream 3 (defined in the beginning) to not interfere with stdout of functions + # which may be used as return value + printf "%b\n" "${cyan:-}dotnet-install:${normal:-} $1" >&3 +} + +say_verbose() { + if [ "$verbose" = true ]; then + say "$1" + fi +} + +# This platform list is finite - if the SDK/Runtime has supported Linux distribution-specific assets, +# then and only then should the Linux distribution appear in this list. +# Adding a Linux distribution to this list does not imply distribution-specific support. +get_legacy_os_name_from_platform() { + eval $invocation + + platform="$1" + case "$platform" in + "centos.7") + echo "centos" + return 0 + ;; + "debian.8") + echo "debian" + return 0 + ;; + "debian.9") + echo "debian.9" + return 0 + ;; + "fedora.23") + echo "fedora.23" + return 0 + ;; + "fedora.24") + echo "fedora.24" + return 0 + ;; + "fedora.27") + echo "fedora.27" + return 0 + ;; + "fedora.28") + echo "fedora.28" + return 0 + ;; + "opensuse.13.2") + echo "opensuse.13.2" + return 0 + ;; + "opensuse.42.1") + echo "opensuse.42.1" + return 0 + ;; + "opensuse.42.3") + echo "opensuse.42.3" + return 0 + ;; + "rhel.7"*) + echo "rhel" + return 0 + ;; + "ubuntu.14.04") + echo "ubuntu" + return 0 + ;; + "ubuntu.16.04") + echo "ubuntu.16.04" + return 0 + ;; + "ubuntu.16.10") + echo "ubuntu.16.10" + return 0 + ;; + "ubuntu.18.04") + echo "ubuntu.18.04" + return 0 + ;; + "alpine.3.4.3") + echo "alpine" + return 0 + ;; + esac + return 1 +} + +get_legacy_os_name() { + eval $invocation + + local uname=$(uname) + if [ "$uname" = "Darwin" ]; then + echo "osx" + return 0 + elif [ -n "$runtime_id" ]; then + echo $(get_legacy_os_name_from_platform "${runtime_id%-*}" || echo "${runtime_id%-*}") + return 0 + else + if [ -e /etc/os-release ]; then + . /etc/os-release + os=$(get_legacy_os_name_from_platform "$ID${VERSION_ID:+.${VERSION_ID}}" || echo "") + if [ -n "$os" ]; then + echo "$os" + return 0 + fi + fi + fi + + say_verbose "Distribution specific OS name and version could not be detected: UName = $uname" + return 1 +} + +get_linux_platform_name() { + eval $invocation + + if [ -n "$runtime_id" ]; then + echo "${runtime_id%-*}" + return 0 + else + if [ -e /etc/os-release ]; then + . /etc/os-release + echo "$ID${VERSION_ID:+.${VERSION_ID}}" + return 0 + elif [ -e /etc/redhat-release ]; then + local redhatRelease=$(&1 || true) | grep -q musl +} + +get_current_os_name() { + eval $invocation + + local uname=$(uname) + if [ "$uname" = "Darwin" ]; then + echo "osx" + return 0 + elif [ "$uname" = "FreeBSD" ]; then + echo "freebsd" + return 0 + elif [ "$uname" = "Linux" ]; then + local linux_platform_name="" + linux_platform_name="$(get_linux_platform_name)" || true + + if [ "$linux_platform_name" = "rhel.6" ]; then + echo $linux_platform_name + return 0 + elif is_musl_based_distro; then + echo "linux-musl" + return 0 + elif [ "$linux_platform_name" = "linux-musl" ]; then + echo "linux-musl" + return 0 + else + echo "linux" + return 0 + fi + fi + + say_err "OS name could not be detected: UName = $uname" + return 1 +} + +machine_has() { + eval $invocation + + command -v "$1" > /dev/null 2>&1 + return $? +} + +check_min_reqs() { + local hasMinimum=false + if machine_has "curl"; then + hasMinimum=true + elif machine_has "wget"; then + hasMinimum=true + fi + + if [ "$hasMinimum" = "false" ]; then + say_err "curl (recommended) or wget are required to download dotnet. Install missing prerequisite to proceed." + return 1 + fi + return 0 +} + +# args: +# input - $1 +to_lowercase() { + #eval $invocation + + echo "$1" | tr '[:upper:]' '[:lower:]' + return 0 +} + +# args: +# input - $1 +remove_trailing_slash() { + #eval $invocation + + local input="${1:-}" + echo "${input%/}" + return 0 +} + +# args: +# input - $1 +remove_beginning_slash() { + #eval $invocation + + local input="${1:-}" + echo "${input#/}" + return 0 +} + +# args: +# root_path - $1 +# child_path - $2 - this parameter can be empty +combine_paths() { + eval $invocation + + # TODO: Consider making it work with any number of paths. For now: + if [ ! -z "${3:-}" ]; then + say_err "combine_paths: Function takes two parameters." + return 1 + fi + + local root_path="$(remove_trailing_slash "$1")" + local child_path="$(remove_beginning_slash "${2:-}")" + say_verbose "combine_paths: root_path=$root_path" + say_verbose "combine_paths: child_path=$child_path" + echo "$root_path/$child_path" + return 0 +} + +get_machine_architecture() { + eval $invocation + + if command -v uname > /dev/null; then + CPUName=$(uname -m) + case $CPUName in + armv1*|armv2*|armv3*|armv4*|armv5*|armv6*) + echo "armv6-or-below" + return 0 + ;; + armv*l) + echo "arm" + return 0 + ;; + aarch64|arm64) + if [ "$(getconf LONG_BIT)" -lt 64 ]; then + # This is 32-bit OS running on 64-bit CPU (for example Raspberry Pi OS) + echo "arm" + return 0 + fi + echo "arm64" + return 0 + ;; + s390x) + echo "s390x" + return 0 + ;; + ppc64le) + echo "ppc64le" + return 0 + ;; + loongarch64) + echo "loongarch64" + return 0 + ;; + riscv64) + echo "riscv64" + return 0 + ;; + powerpc|ppc) + echo "ppc" + return 0 + ;; + esac + fi + + # Always default to 'x64' + echo "x64" + return 0 +} + +# args: +# architecture - $1 +get_normalized_architecture_from_architecture() { + eval $invocation + + local architecture="$(to_lowercase "$1")" + + if [[ $architecture == \ ]]; then + machine_architecture="$(get_machine_architecture)" + if [[ "$machine_architecture" == "armv6-or-below" ]]; then + say_err "Architecture \`$machine_architecture\` not supported. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues" + return 1 + fi + + echo $machine_architecture + return 0 + fi + + case "$architecture" in + amd64|x64) + echo "x64" + return 0 + ;; + arm) + echo "arm" + return 0 + ;; + arm64) + echo "arm64" + return 0 + ;; + s390x) + echo "s390x" + return 0 + ;; + ppc64le) + echo "ppc64le" + return 0 + ;; + loongarch64) + echo "loongarch64" + return 0 + ;; + esac + + say_err "Architecture \`$architecture\` not supported. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues" + return 1 +} + +# args: +# version - $1 +# channel - $2 +# architecture - $3 +get_normalized_architecture_for_specific_sdk_version() { + eval $invocation + + local is_version_support_arm64="$(is_arm64_supported "$1")" + local is_channel_support_arm64="$(is_arm64_supported "$2")" + local architecture="$3"; + local osname="$(get_current_os_name)" + + if [ "$osname" == "osx" ] && [ "$architecture" == "arm64" ] && { [ "$is_version_support_arm64" = false ] || [ "$is_channel_support_arm64" = false ]; }; then + #check if rosetta is installed + if [ "$(/usr/bin/pgrep oahd >/dev/null 2>&1;echo $?)" -eq 0 ]; then + say_verbose "Changing user architecture from '$architecture' to 'x64' because .NET SDKs prior to version 6.0 do not support arm64." + echo "x64" + return 0; + else + say_err "Architecture \`$architecture\` is not supported for .NET SDK version \`$version\`. Please install Rosetta to allow emulation of the \`$architecture\` .NET SDK on this platform" + return 1 + fi + fi + + echo "$architecture" + return 0 +} + +# args: +# version or channel - $1 +is_arm64_supported() { + # Extract the major version by splitting on the dot + major_version="${1%%.*}" + + # Check if the major version is a valid number and less than 6 + case "$major_version" in + [0-9]*) + if [ "$major_version" -lt 6 ]; then + echo false + return 0 + fi + ;; + esac + + echo true + return 0 +} + +# args: +# user_defined_os - $1 +get_normalized_os() { + eval $invocation + + local osname="$(to_lowercase "$1")" + if [ ! -z "$osname" ]; then + case "$osname" in + osx | freebsd | rhel.6 | linux-musl | linux) + echo "$osname" + return 0 + ;; + macos) + osname='osx' + echo "$osname" + return 0 + ;; + *) + say_err "'$user_defined_os' is not a supported value for --os option, supported values are: osx, macos, linux, linux-musl, freebsd, rhel.6. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues." + return 1 + ;; + esac + else + osname="$(get_current_os_name)" || return 1 + fi + echo "$osname" + return 0 +} + +# args: +# quality - $1 +get_normalized_quality() { + eval $invocation + + local quality="$(to_lowercase "$1")" + if [ ! -z "$quality" ]; then + case "$quality" in + daily | preview) + echo "$quality" + return 0 + ;; + ga) + #ga quality is available without specifying quality, so normalizing it to empty + return 0 + ;; + *) + say_err "'$quality' is not a supported value for --quality option. Supported values are: daily, preview, ga. If you think this is a bug, report it at https://github.com/dotnet/install-scripts/issues." + return 1 + ;; + esac + fi + return 0 +} + +# args: +# channel - $1 +get_normalized_channel() { + eval $invocation + + local channel="$(to_lowercase "$1")" + + if [[ $channel == current ]]; then + say_warning 'Value "Current" is deprecated for -Channel option. Use "STS" instead.' + fi + + if [[ $channel == release/* ]]; then + say_warning 'Using branch name with -Channel option is no longer supported with newer releases. Use -Quality option with a channel in X.Y format instead.'; + fi + + if [ ! -z "$channel" ]; then + case "$channel" in + lts) + echo "LTS" + return 0 + ;; + sts) + echo "STS" + return 0 + ;; + current) + echo "STS" + return 0 + ;; + *) + echo "$channel" + return 0 + ;; + esac + fi + + return 0 +} + +# args: +# runtime - $1 +get_normalized_product() { + eval $invocation + + local product="" + local runtime="$(to_lowercase "$1")" + if [[ "$runtime" == "dotnet" ]]; then + product="dotnet-runtime" + elif [[ "$runtime" == "aspnetcore" ]]; then + product="aspnetcore-runtime" + elif [ -z "$runtime" ]; then + product="dotnet-sdk" + fi + echo "$product" + return 0 +} + +# The version text returned from the feeds is a 1-line or 2-line string: +# For the SDK and the dotnet runtime (2 lines): +# Line 1: # commit_hash +# Line 2: # 4-part version +# For the aspnetcore runtime (1 line): +# Line 1: # 4-part version + +# args: +# version_text - stdin +get_version_from_latestversion_file_content() { + eval $invocation + + cat | tail -n 1 | sed 's/\r$//' + return 0 +} + +# args: +# install_root - $1 +# relative_path_to_package - $2 +# specific_version - $3 +is_dotnet_package_installed() { + eval $invocation + + local install_root="$1" + local relative_path_to_package="$2" + local specific_version="${3//[$'\t\r\n']}" + + local dotnet_package_path="$(combine_paths "$(combine_paths "$install_root" "$relative_path_to_package")" "$specific_version")" + say_verbose "is_dotnet_package_installed: dotnet_package_path=$dotnet_package_path" + + if [ -d "$dotnet_package_path" ]; then + return 0 + else + return 1 + fi +} + +# args: +# downloaded file - $1 +# remote_file_size - $2 +validate_remote_local_file_sizes() +{ + eval $invocation + + local downloaded_file="$1" + local remote_file_size="$2" + local file_size='' + + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + file_size="$(stat -c '%s' "$downloaded_file")" + elif [[ "$OSTYPE" == "darwin"* ]]; then + # hardcode in order to avoid conflicts with GNU stat + file_size="$(/usr/bin/stat -f '%z' "$downloaded_file")" + fi + + if [ -n "$file_size" ]; then + say "Downloaded file size is $file_size bytes." + + if [ -n "$remote_file_size" ] && [ -n "$file_size" ]; then + if [ "$remote_file_size" -ne "$file_size" ]; then + say "The remote and local file sizes are not equal. The remote file size is $remote_file_size bytes and the local size is $file_size bytes. The local package may be corrupted." + else + say "The remote and local file sizes are equal." + fi + fi + + else + say "Either downloaded or local package size can not be measured. One of them may be corrupted." + fi +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +get_version_from_latestversion_file() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + + local version_file_url=null + if [[ "$runtime" == "dotnet" ]]; then + version_file_url="$azure_feed/Runtime/$channel/latest.version" + elif [[ "$runtime" == "aspnetcore" ]]; then + version_file_url="$azure_feed/aspnetcore/Runtime/$channel/latest.version" + elif [ -z "$runtime" ]; then + version_file_url="$azure_feed/Sdk/$channel/latest.version" + else + say_err "Invalid value for \$runtime" + return 1 + fi + say_verbose "get_version_from_latestversion_file: latest url: $version_file_url" + + download "$version_file_url" || return $? + return 0 +} + +# args: +# json_file - $1 +parse_globaljson_file_for_version() { + eval $invocation + + local json_file="$1" + if [ ! -f "$json_file" ]; then + say_err "Unable to find \`$json_file\`" + return 1 + fi + + sdk_section=$(cat $json_file | tr -d "\r" | awk '/"sdk"/,/}/') + if [ -z "$sdk_section" ]; then + say_err "Unable to parse the SDK node in \`$json_file\`" + return 1 + fi + + sdk_list=$(echo $sdk_section | awk -F"[{}]" '{print $2}') + sdk_list=${sdk_list//[\" ]/} + sdk_list=${sdk_list//,/$'\n'} + + local version_info="" + while read -r line; do + IFS=: + while read -r key value; do + if [[ "$key" == "version" ]]; then + version_info=$value + fi + done <<< "$line" + done <<< "$sdk_list" + if [ -z "$version_info" ]; then + say_err "Unable to find the SDK:version node in \`$json_file\`" + return 1 + fi + + unset IFS; + echo "$version_info" + return 0 +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# version - $4 +# json_file - $5 +get_specific_version_from_version() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local version="$(to_lowercase "$4")" + local json_file="$5" + + if [ -z "$json_file" ]; then + if [[ "$version" == "latest" ]]; then + local version_info + version_info="$(get_version_from_latestversion_file "$azure_feed" "$channel" "$normalized_architecture" false)" || return 1 + say_verbose "get_specific_version_from_version: version_info=$version_info" + echo "$version_info" | get_version_from_latestversion_file_content + return 0 + else + echo "$version" + return 0 + fi + else + local version_info + version_info="$(parse_globaljson_file_for_version "$json_file")" || return 1 + echo "$version_info" + return 0 + fi +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# specific_version - $4 +# normalized_os - $5 +construct_download_link() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local specific_version="${4//[$'\t\r\n']}" + local specific_product_version="$(get_specific_product_version "$1" "$4")" + local osname="$5" + + local download_link=null + if [[ "$runtime" == "dotnet" ]]; then + download_link="$azure_feed/Runtime/$specific_version/dotnet-runtime-$specific_product_version-$osname-$normalized_architecture.tar.gz" + elif [[ "$runtime" == "aspnetcore" ]]; then + download_link="$azure_feed/aspnetcore/Runtime/$specific_version/aspnetcore-runtime-$specific_product_version-$osname-$normalized_architecture.tar.gz" + elif [ -z "$runtime" ]; then + download_link="$azure_feed/Sdk/$specific_version/dotnet-sdk-$specific_product_version-$osname-$normalized_architecture.tar.gz" + else + return 1 + fi + + echo "$download_link" + return 0 +} + +# args: +# azure_feed - $1 +# specific_version - $2 +# download link - $3 (optional) +get_specific_product_version() { + # If we find a 'productVersion.txt' at the root of any folder, we'll use its contents + # to resolve the version of what's in the folder, superseding the specified version. + # if 'productVersion.txt' is missing but download link is already available, product version will be taken from download link + eval $invocation + + local azure_feed="$1" + local specific_version="${2//[$'\t\r\n']}" + local package_download_link="" + if [ $# -gt 2 ]; then + local package_download_link="$3" + fi + local specific_product_version=null + + # Try to get the version number, using the productVersion.txt file located next to the installer file. + local download_links=($(get_specific_product_version_url "$azure_feed" "$specific_version" true "$package_download_link") + $(get_specific_product_version_url "$azure_feed" "$specific_version" false "$package_download_link")) + + for download_link in "${download_links[@]}" + do + say_verbose "Checking for the existence of $download_link" + + if machine_has "curl" + then + if ! specific_product_version=$(curl -s --fail "${download_link}${feed_credential}" 2>&1); then + continue + else + echo "${specific_product_version//[$'\t\r\n']}" + return 0 + fi + + elif machine_has "wget" + then + specific_product_version=$(wget -qO- "${download_link}${feed_credential}" 2>&1) + if [ $? = 0 ]; then + echo "${specific_product_version//[$'\t\r\n']}" + return 0 + fi + fi + done + + # Getting the version number with productVersion.txt has failed. Try parsing the download link for a version number. + say_verbose "Failed to get the version using productVersion.txt file. Download link will be parsed instead." + specific_product_version="$(get_product_specific_version_from_download_link "$package_download_link" "$specific_version")" + echo "${specific_product_version//[$'\t\r\n']}" + return 0 +} + +# args: +# azure_feed - $1 +# specific_version - $2 +# is_flattened - $3 +# download link - $4 (optional) +get_specific_product_version_url() { + eval $invocation + + local azure_feed="$1" + local specific_version="$2" + local is_flattened="$3" + local package_download_link="" + if [ $# -gt 3 ]; then + local package_download_link="$4" + fi + + local pvFileName="productVersion.txt" + if [ "$is_flattened" = true ]; then + if [ -z "$runtime" ]; then + pvFileName="sdk-productVersion.txt" + elif [[ "$runtime" == "dotnet" ]]; then + pvFileName="runtime-productVersion.txt" + else + pvFileName="$runtime-productVersion.txt" + fi + fi + + local download_link=null + + if [ -z "$package_download_link" ]; then + if [[ "$runtime" == "dotnet" ]]; then + download_link="$azure_feed/Runtime/$specific_version/${pvFileName}" + elif [[ "$runtime" == "aspnetcore" ]]; then + download_link="$azure_feed/aspnetcore/Runtime/$specific_version/${pvFileName}" + elif [ -z "$runtime" ]; then + download_link="$azure_feed/Sdk/$specific_version/${pvFileName}" + else + return 1 + fi + else + download_link="${package_download_link%/*}/${pvFileName}" + fi + + say_verbose "Constructed productVersion link: $download_link" + echo "$download_link" + return 0 +} + +# args: +# download link - $1 +# specific version - $2 +get_product_specific_version_from_download_link() +{ + eval $invocation + + local download_link="$1" + local specific_version="$2" + local specific_product_version="" + + if [ -z "$download_link" ]; then + echo "$specific_version" + return 0 + fi + + #get filename + filename="${download_link##*/}" + + #product specific version follows the product name + #for filename 'dotnet-sdk-3.1.404-linux-x64.tar.gz': the product version is 3.1.404 + IFS='-' + read -ra filename_elems <<< "$filename" + count=${#filename_elems[@]} + if [[ "$count" -gt 2 ]]; then + specific_product_version="${filename_elems[2]}" + else + specific_product_version=$specific_version + fi + unset IFS; + echo "$specific_product_version" + return 0 +} + +# args: +# azure_feed - $1 +# channel - $2 +# normalized_architecture - $3 +# specific_version - $4 +construct_legacy_download_link() { + eval $invocation + + local azure_feed="$1" + local channel="$2" + local normalized_architecture="$3" + local specific_version="${4//[$'\t\r\n']}" + + local distro_specific_osname + distro_specific_osname="$(get_legacy_os_name)" || return 1 + + local legacy_download_link=null + if [[ "$runtime" == "dotnet" ]]; then + legacy_download_link="$azure_feed/Runtime/$specific_version/dotnet-$distro_specific_osname-$normalized_architecture.$specific_version.tar.gz" + elif [ -z "$runtime" ]; then + legacy_download_link="$azure_feed/Sdk/$specific_version/dotnet-dev-$distro_specific_osname-$normalized_architecture.$specific_version.tar.gz" + else + return 1 + fi + + echo "$legacy_download_link" + return 0 +} + +get_user_install_path() { + eval $invocation + + if [ ! -z "${DOTNET_INSTALL_DIR:-}" ]; then + echo "$DOTNET_INSTALL_DIR" + else + echo "$HOME/.dotnet" + fi + return 0 +} + +# args: +# install_dir - $1 +resolve_installation_path() { + eval $invocation + + local install_dir=$1 + if [ "$install_dir" = "" ]; then + local user_install_path="$(get_user_install_path)" + say_verbose "resolve_installation_path: user_install_path=$user_install_path" + echo "$user_install_path" + return 0 + fi + + echo "$install_dir" + return 0 +} + +# args: +# relative_or_absolute_path - $1 +get_absolute_path() { + eval $invocation + + local relative_or_absolute_path=$1 + echo "$(cd "$(dirname "$1")" && pwd -P)/$(basename "$1")" + return 0 +} + +# args: +# override - $1 (boolean, true or false) +get_cp_options() { + eval $invocation + + local override="$1" + local override_switch="" + + if [ "$override" = false ]; then + override_switch="-n" + + # create temporary files to check if 'cp -u' is supported + tmp_dir="$(mktemp -d)" + tmp_file="$tmp_dir/testfile" + tmp_file2="$tmp_dir/testfile2" + + touch "$tmp_file" + + # use -u instead of -n if it's available + if cp -u "$tmp_file" "$tmp_file2" 2>/dev/null; then + override_switch="-u" + fi + + # clean up + rm -f "$tmp_file" "$tmp_file2" + rm -rf "$tmp_dir" + fi + + echo "$override_switch" +} + +# args: +# input_files - stdin +# root_path - $1 +# out_path - $2 +# override - $3 +copy_files_or_dirs_from_list() { + eval $invocation + + local root_path="$(remove_trailing_slash "$1")" + local out_path="$(remove_trailing_slash "$2")" + local override="$3" + local override_switch="$(get_cp_options "$override")" + + cat | uniq | while read -r file_path; do + local path="$(remove_beginning_slash "${file_path#$root_path}")" + local target="$out_path/$path" + if [ "$override" = true ] || (! ([ -d "$target" ] || [ -e "$target" ])); then + mkdir -p "$out_path/$(dirname "$path")" + if [ -d "$target" ]; then + rm -rf "$target" + fi + cp -R $override_switch "$root_path/$path" "$target" + fi + done +} + +# args: +# zip_uri - $1 +get_remote_file_size() { + local zip_uri="$1" + + if machine_has "curl"; then + file_size=$(curl -sI "$zip_uri" | grep -i content-length | awk '{ num = $2 + 0; print num }') + elif machine_has "wget"; then + file_size=$(wget --spider --server-response -O /dev/null "$zip_uri" 2>&1 | grep -i 'Content-Length:' | awk '{ num = $2 + 0; print num }') + else + say "Neither curl nor wget is available on this system." + return + fi + + if [ -n "$file_size" ]; then + say "Remote file $zip_uri size is $file_size bytes." + echo "$file_size" + else + say_verbose "Content-Length header was not extracted for $zip_uri." + echo "" + fi +} + +# args: +# zip_path - $1 +# out_path - $2 +# remote_file_size - $3 +extract_dotnet_package() { + eval $invocation + + local zip_path="$1" + local out_path="$2" + local remote_file_size="$3" + + local temp_out_path="$(mktemp -d "$temporary_file_template")" + + local failed=false + tar -xzf "$zip_path" -C "$temp_out_path" > /dev/null || failed=true + + local folders_with_version_regex='^.*/[0-9]+\.[0-9]+[^/]+/' + find "$temp_out_path" -type f | grep -Eo "$folders_with_version_regex" | sort | copy_files_or_dirs_from_list "$temp_out_path" "$out_path" false + find "$temp_out_path" -type f | grep -Ev "$folders_with_version_regex" | copy_files_or_dirs_from_list "$temp_out_path" "$out_path" "$override_non_versioned_files" + + validate_remote_local_file_sizes "$zip_path" "$remote_file_size" + + rm -rf "$temp_out_path" + if [ -z ${keep_zip+x} ]; then + rm -f "$zip_path" && say_verbose "Temporary archive file $zip_path was removed" + fi + + if [ "$failed" = true ]; then + say_err "Extraction failed" + return 1 + fi + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header() +{ + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + + local failed=false + local response + if machine_has "curl"; then + get_http_header_curl $remote_path $disable_feed_credential || failed=true + elif machine_has "wget"; then + get_http_header_wget $remote_path $disable_feed_credential || failed=true + else + failed=true + fi + if [ "$failed" = true ]; then + say_verbose "Failed to get HTTP header: '$remote_path'." + return 1 + fi + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header_curl() { + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + + remote_path_with_credential="$remote_path" + if [ "$disable_feed_credential" = false ]; then + remote_path_with_credential+="$feed_credential" + fi + + curl_options="-I -sSL --retry 5 --retry-delay 2 --connect-timeout 15 " + curl $curl_options "$remote_path_with_credential" 2>&1 || return 1 + return 0 +} + +# args: +# remote_path - $1 +# disable_feed_credential - $2 +get_http_header_wget() { + eval $invocation + local remote_path="$1" + local disable_feed_credential="$2" + local wget_options="-q -S --spider --tries 5 " + + local wget_options_extra='' + + # Test for options that aren't supported on all wget implementations. + if [[ $(wget -h 2>&1 | grep -E 'waitretry|connect-timeout') ]]; then + wget_options_extra="--waitretry 2 --connect-timeout 15 " + else + say "wget extra options are unavailable for this environment" + fi + + remote_path_with_credential="$remote_path" + if [ "$disable_feed_credential" = false ]; then + remote_path_with_credential+="$feed_credential" + fi + + wget $wget_options $wget_options_extra "$remote_path_with_credential" 2>&1 + + return $? +} + +# args: +# remote_path - $1 +# [out_path] - $2 - stdout if not provided +download() { + eval $invocation + + local remote_path="$1" + local out_path="${2:-}" + + if [[ "$remote_path" != "http"* ]]; then + cp "$remote_path" "$out_path" + return $? + fi + + local failed=false + local attempts=0 + while [ $attempts -lt 3 ]; do + attempts=$((attempts+1)) + failed=false + if machine_has "curl"; then + downloadcurl "$remote_path" "$out_path" || failed=true + elif machine_has "wget"; then + downloadwget "$remote_path" "$out_path" || failed=true + else + say_err "Missing dependency: neither curl nor wget was found." + exit 1 + fi + + if [ "$failed" = false ] || [ $attempts -ge 3 ] || { [ ! -z $http_code ] && [ $http_code = "404" ]; }; then + break + fi + + say "Download attempt #$attempts has failed: $http_code $download_error_msg" + say "Attempt #$((attempts+1)) will start in $((attempts*10)) seconds." + sleep $((attempts*10)) + done + + if [ "$failed" = true ]; then + say_verbose "Download failed: $remote_path" + return 1 + fi + return 0 +} + +# Updates global variables $http_code and $download_error_msg +downloadcurl() { + eval $invocation + unset http_code + unset download_error_msg + local remote_path="$1" + local out_path="${2:-}" + # Append feed_credential as late as possible before calling curl to avoid logging feed_credential + # Avoid passing URI with credentials to functions: note, most of them echoing parameters of invocation in verbose output. + local remote_path_with_credential="${remote_path}${feed_credential}" + local curl_options="--retry 20 --retry-delay 2 --connect-timeout 15 -sSL -f --create-dirs " + local curl_exit_code=0; + if [ -z "$out_path" ]; then + curl_output=$(curl $curl_options "$remote_path_with_credential" 2>&1) + curl_exit_code=$? + echo "$curl_output" + else + curl_output=$(curl $curl_options -o "$out_path" "$remote_path_with_credential" 2>&1) + curl_exit_code=$? + fi + + # Regression in curl causes curl with --retry to return a 0 exit code even when it fails to download a file - https://github.com/curl/curl/issues/17554 + if [ $curl_exit_code -eq 0 ] && echo "$curl_output" | grep -q "^curl: ([0-9]*) "; then + curl_exit_code=$(echo "$curl_output" | sed 's/curl: (\([0-9]*\)).*/\1/') + fi + + if [ $curl_exit_code -gt 0 ]; then + download_error_msg="Unable to download $remote_path." + # Check for curl timeout codes + if [[ $curl_exit_code == 7 || $curl_exit_code == 28 ]]; then + download_error_msg+=" Failed to reach the server: connection timeout." + else + local disable_feed_credential=false + local response=$(get_http_header_curl $remote_path $disable_feed_credential) + http_code=$( echo "$response" | awk '/^HTTP/{print $2}' | tail -1 ) + if [[ ! -z $http_code && $http_code != 2* ]]; then + download_error_msg+=" Returned HTTP status code: $http_code." + fi + fi + say_verbose "$download_error_msg" + return 1 + fi + return 0 +} + + +# Updates global variables $http_code and $download_error_msg +downloadwget() { + eval $invocation + unset http_code + unset download_error_msg + local remote_path="$1" + local out_path="${2:-}" + # Append feed_credential as late as possible before calling wget to avoid logging feed_credential + local remote_path_with_credential="${remote_path}${feed_credential}" + local wget_options="--tries 20 " + + local wget_options_extra='' + local wget_result='' + + # Test for options that aren't supported on all wget implementations. + if [[ $(wget -h 2>&1 | grep -E 'waitretry|connect-timeout') ]]; then + wget_options_extra="--waitretry 2 --connect-timeout 15 " + else + say "wget extra options are unavailable for this environment" + fi + + if [ -z "$out_path" ]; then + wget -q $wget_options $wget_options_extra -O - "$remote_path_with_credential" 2>&1 + wget_result=$? + else + wget $wget_options $wget_options_extra -O "$out_path" "$remote_path_with_credential" 2>&1 + wget_result=$? + fi + + if [[ $wget_result != 0 ]]; then + local disable_feed_credential=false + local response=$(get_http_header_wget $remote_path $disable_feed_credential) + http_code=$( echo "$response" | awk '/^ HTTP/{print $2}' | tail -1 ) + download_error_msg="Unable to download $remote_path." + if [[ ! -z $http_code && $http_code != 2* ]]; then + download_error_msg+=" Returned HTTP status code: $http_code." + # wget exit code 4 stands for network-issue + elif [[ $wget_result == 4 ]]; then + download_error_msg+=" Failed to reach the server: connection timeout." + fi + say_verbose "$download_error_msg" + return 1 + fi + + return 0 +} + +get_download_link_from_aka_ms() { + eval $invocation + + #quality is not supported for LTS or STS channel + #STS maps to current + if [[ ! -z "$normalized_quality" && ("$normalized_channel" == "LTS" || "$normalized_channel" == "STS") ]]; then + normalized_quality="" + say_warning "Specifying quality for STS or LTS channel is not supported, the quality will be ignored." + fi + + say_verbose "Retrieving primary payload URL from aka.ms for channel: '$normalized_channel', quality: '$normalized_quality', product: '$normalized_product', os: '$normalized_os', architecture: '$normalized_architecture'." + + #construct aka.ms link + aka_ms_link="https://aka.ms/dotnet" + if [ "$internal" = true ]; then + aka_ms_link="$aka_ms_link/internal" + fi + aka_ms_link="$aka_ms_link/$normalized_channel" + if [[ ! -z "$normalized_quality" ]]; then + aka_ms_link="$aka_ms_link/$normalized_quality" + fi + aka_ms_link="$aka_ms_link/$normalized_product-$normalized_os-$normalized_architecture.tar.gz" + say_verbose "Constructed aka.ms link: '$aka_ms_link'." + + #get HTTP response + #do not pass credentials as a part of the $aka_ms_link and do not apply credentials in the get_http_header function + #otherwise the redirect link would have credentials as well + #it would result in applying credentials twice to the resulting link and thus breaking it, and in echoing credentials to the output as a part of redirect link + disable_feed_credential=true + response="$(get_http_header $aka_ms_link $disable_feed_credential)" + + say_verbose "Received response: $response" + # Get results of all the redirects. + http_codes=$( echo "$response" | awk '$1 ~ /^HTTP/ {print $2}' ) + # They all need to be 301, otherwise some links are broken (except for the last, which is not a redirect but 200 or 404). + broken_redirects=$( echo "$http_codes" | sed '$d' | grep -v '301' ) + # The response may end without final code 2xx/4xx/5xx somehow, e.g. network restrictions on www.bing.com causes redirecting to bing.com fails with connection refused. + # In this case it should not exclude the last. + last_http_code=$( echo "$http_codes" | tail -n 1 ) + if ! [[ $last_http_code =~ ^(2|4|5)[0-9][0-9]$ ]]; then + broken_redirects=$( echo "$http_codes" | grep -v '301' ) + fi + + # All HTTP codes are 301 (Moved Permanently), the redirect link exists. + if [[ -z "$broken_redirects" ]]; then + aka_ms_download_link=$( echo "$response" | awk '$1 ~ /^Location/{print $2}' | tail -1 | tr -d '\r') + + if [[ -z "$aka_ms_download_link" ]]; then + say_verbose "The aka.ms link '$aka_ms_link' is not valid: failed to get redirect location." + return 1 + fi + + say_verbose "The redirect location retrieved: '$aka_ms_download_link'." + return 0 + else + say_verbose "The aka.ms link '$aka_ms_link' is not valid: received HTTP code: $(echo "$broken_redirects" | paste -sd "," -)." + return 1 + fi +} + +get_feeds_to_use() +{ + feeds=( + "https://builds.dotnet.microsoft.com/dotnet" + "https://ci.dot.net/public" + ) + + if [[ -n "$azure_feed" ]]; then + feeds=("$azure_feed") + fi + + if [[ -n "$uncached_feed" ]]; then + feeds=("$uncached_feed") + fi +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed). +generate_download_links() { + + download_links=() + specific_versions=() + effective_versions=() + link_types=() + + # If generate_akams_links returns false, no fallback to old links. Just terminate. + # This function may also 'exit' (if the determined version is already installed). + generate_akams_links || return + + # Check other feeds only if we haven't been able to find an aka.ms link. + if [[ "${#download_links[@]}" -lt 1 ]]; then + for feed in ${feeds[@]} + do + # generate_regular_links may also 'exit' (if the determined version is already installed). + generate_regular_links $feed || return + done + fi + + if [[ "${#download_links[@]}" -eq 0 ]]; then + say_err "Failed to resolve the exact version number." + return 1 + fi + + say_verbose "Generated ${#download_links[@]} links." + for link_index in ${!download_links[@]} + do + say_verbose "Link $link_index: ${link_types[$link_index]}, ${effective_versions[$link_index]}, ${download_links[$link_index]}" + done +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed). +generate_akams_links() { + local valid_aka_ms_link=true; + + normalized_version="$(to_lowercase "$version")" + if [[ "$normalized_version" != "latest" ]] && [ -n "$normalized_quality" ]; then + say_err "Quality and Version options are not allowed to be specified simultaneously. See https://learn.microsoft.com/dotnet/core/tools/dotnet-install-script#options for details." + return 1 + fi + + if [[ -n "$json_file" || "$normalized_version" != "latest" ]]; then + # aka.ms links are not needed when exact version is specified via command or json file + return + fi + + get_download_link_from_aka_ms || valid_aka_ms_link=false + + if [[ "$valid_aka_ms_link" == true ]]; then + say_verbose "Retrieved primary payload URL from aka.ms link: '$aka_ms_download_link'." + say_verbose "Downloading using legacy url will not be attempted." + + download_link=$aka_ms_download_link + + #get version from the path + IFS='/' + read -ra pathElems <<< "$download_link" + count=${#pathElems[@]} + specific_version="${pathElems[count-2]}" + unset IFS; + say_verbose "Version: '$specific_version'." + + #Retrieve effective version + effective_version="$(get_specific_product_version "$azure_feed" "$specific_version" "$download_link")" + + # Add link info to arrays + download_links+=($download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("aka.ms") + + # Check if the SDK version is already installed. + if [[ "$dry_run" != true ]] && is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "$asset_name with version '$effective_version' is already installed." + exit 0 + fi + + return 0 + fi + + # if quality is specified - exit with error - there is no fallback approach + if [ ! -z "$normalized_quality" ]; then + say_err "Failed to locate the latest version in the channel '$normalized_channel' with '$normalized_quality' quality for '$normalized_product', os: '$normalized_os', architecture: '$normalized_architecture'." + say_err "Refer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support." + return 1 + fi + say_verbose "Falling back to latest.version file approach." +} + +# THIS FUNCTION MAY EXIT (if the determined version is already installed) +# args: +# feed - $1 +generate_regular_links() { + local feed="$1" + local valid_legacy_download_link=true + + specific_version=$(get_specific_version_from_version "$feed" "$channel" "$normalized_architecture" "$version" "$json_file") || specific_version='0' + + if [[ "$specific_version" == '0' ]]; then + say_verbose "Failed to resolve the specific version number using feed '$feed'" + return + fi + + effective_version="$(get_specific_product_version "$feed" "$specific_version")" + say_verbose "specific_version=$specific_version" + + download_link="$(construct_download_link "$feed" "$channel" "$normalized_architecture" "$specific_version" "$normalized_os")" + say_verbose "Constructed primary named payload URL: $download_link" + + # Add link info to arrays + download_links+=($download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("primary") + + legacy_download_link="$(construct_legacy_download_link "$feed" "$channel" "$normalized_architecture" "$specific_version")" || valid_legacy_download_link=false + + if [ "$valid_legacy_download_link" = true ]; then + say_verbose "Constructed legacy named payload URL: $legacy_download_link" + + download_links+=($legacy_download_link) + specific_versions+=($specific_version) + effective_versions+=($effective_version) + link_types+=("legacy") + else + legacy_download_link="" + say_verbose "Could not construct a legacy_download_link; omitting..." + fi + + # Check if the SDK version is already installed. + if [[ "$dry_run" != true ]] && is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "$asset_name with version '$effective_version' is already installed." + exit 0 + fi +} + +print_dry_run() { + + say "Payload URLs:" + + for link_index in "${!download_links[@]}" + do + say "URL #$link_index - ${link_types[$link_index]}: ${download_links[$link_index]}" + done + + resolved_version=${specific_versions[0]} + repeatable_command="./$script_name --version "\""$resolved_version"\"" --install-dir "\""$install_root"\"" --architecture "\""$normalized_architecture"\"" --os "\""$normalized_os"\""" + + if [ ! -z "$normalized_quality" ]; then + repeatable_command+=" --quality "\""$normalized_quality"\""" + fi + + if [[ "$runtime" == "dotnet" ]]; then + repeatable_command+=" --runtime "\""dotnet"\""" + elif [[ "$runtime" == "aspnetcore" ]]; then + repeatable_command+=" --runtime "\""aspnetcore"\""" + fi + + repeatable_command+="$non_dynamic_parameters" + + if [ -n "$feed_credential" ]; then + repeatable_command+=" --feed-credential "\"""\""" + fi + + say "Repeatable invocation: $repeatable_command" +} + +calculate_vars() { + eval $invocation + + script_name=$(basename "$0") + normalized_architecture="$(get_normalized_architecture_from_architecture "$architecture")" + say_verbose "Normalized architecture: '$normalized_architecture'." + normalized_os="$(get_normalized_os "$user_defined_os")" + say_verbose "Normalized OS: '$normalized_os'." + normalized_quality="$(get_normalized_quality "$quality")" + say_verbose "Normalized quality: '$normalized_quality'." + normalized_channel="$(get_normalized_channel "$channel")" + say_verbose "Normalized channel: '$normalized_channel'." + normalized_product="$(get_normalized_product "$runtime")" + say_verbose "Normalized product: '$normalized_product'." + install_root="$(resolve_installation_path "$install_dir")" + say_verbose "InstallRoot: '$install_root'." + + normalized_architecture="$(get_normalized_architecture_for_specific_sdk_version "$version" "$normalized_channel" "$normalized_architecture")" + + if [[ "$runtime" == "dotnet" ]]; then + asset_relative_path="shared/Microsoft.NETCore.App" + asset_name=".NET Core Runtime" + elif [[ "$runtime" == "aspnetcore" ]]; then + asset_relative_path="shared/Microsoft.AspNetCore.App" + asset_name="ASP.NET Core Runtime" + elif [ -z "$runtime" ]; then + asset_relative_path="sdk" + asset_name=".NET Core SDK" + fi + + get_feeds_to_use +} + +install_dotnet() { + eval $invocation + local download_failed=false + local download_completed=false + local remote_file_size=0 + + mkdir -p "$install_root" + zip_path="${zip_path:-$(mktemp "$temporary_file_template")}" + say_verbose "Archive path: $zip_path" + + for link_index in "${!download_links[@]}" + do + download_link="${download_links[$link_index]}" + specific_version="${specific_versions[$link_index]}" + effective_version="${effective_versions[$link_index]}" + link_type="${link_types[$link_index]}" + + say "Attempting to download using $link_type link $download_link" + + # The download function will set variables $http_code and $download_error_msg in case of failure. + download_failed=false + download "$download_link" "$zip_path" 2>&1 || download_failed=true + + if [ "$download_failed" = true ]; then + case $http_code in + 404) + say "The resource at $link_type link '$download_link' is not available." + ;; + *) + say "Failed to download $link_type link '$download_link': $http_code $download_error_msg" + ;; + esac + rm -f "$zip_path" 2>&1 && say_verbose "Temporary archive file $zip_path was removed" + else + download_completed=true + break + fi + done + + if [[ "$download_completed" == false ]]; then + say_err "Could not find \`$asset_name\` with version = $specific_version" + say_err "Refer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support" + return 1 + fi + + remote_file_size="$(get_remote_file_size "$download_link")" + + say "Extracting archive from $download_link" + extract_dotnet_package "$zip_path" "$install_root" "$remote_file_size" || return 1 + + # Check if the SDK version is installed; if not, fail the installation. + # if the version contains "RTM" or "servicing"; check if a 'release-type' SDK version is installed. + if [[ $specific_version == *"rtm"* || $specific_version == *"servicing"* ]]; then + IFS='-' + read -ra verArr <<< "$specific_version" + release_version="${verArr[0]}" + unset IFS; + say_verbose "Checking installation: version = $release_version" + if is_dotnet_package_installed "$install_root" "$asset_relative_path" "$release_version"; then + say "Installed version is $effective_version" + return 0 + fi + fi + + # Check if the standard SDK version is installed. + say_verbose "Checking installation: version = $effective_version" + if is_dotnet_package_installed "$install_root" "$asset_relative_path" "$effective_version"; then + say "Installed version is $effective_version" + return 0 + fi + + # Version verification failed. More likely something is wrong either with the downloaded content or with the verification algorithm. + say_err "Failed to verify the version of installed \`$asset_name\`.\nInstallation source: $download_link.\nInstallation location: $install_root.\nReport the bug at https://github.com/dotnet/install-scripts/issues." + say_err "\`$asset_name\` with version = $effective_version failed to install with an error." + return 1 +} + +args=("$@") + +local_version_file_relative_path="/.version" +bin_folder_relative_path="" +temporary_file_template="${TMPDIR:-/tmp}/dotnet.XXXXXXXXX" + +channel="LTS" +version="Latest" +json_file="" +install_dir="" +architecture="" +dry_run=false +no_path=false +azure_feed="" +uncached_feed="" +feed_credential="" +verbose=false +runtime="" +runtime_id="" +quality="" +internal=false +override_non_versioned_files=true +non_dynamic_parameters="" +user_defined_os="" + +while [ $# -ne 0 ] +do + name="$1" + case "$name" in + -c|--channel|-[Cc]hannel) + shift + channel="$1" + ;; + -v|--version|-[Vv]ersion) + shift + version="$1" + ;; + -q|--quality|-[Qq]uality) + shift + quality="$1" + ;; + --internal|-[Ii]nternal) + internal=true + non_dynamic_parameters+=" $name" + ;; + -i|--install-dir|-[Ii]nstall[Dd]ir) + shift + install_dir="$1" + ;; + --arch|--architecture|-[Aa]rch|-[Aa]rchitecture) + shift + architecture="$1" + ;; + --os|-[Oo][SS]) + shift + user_defined_os="$1" + ;; + --shared-runtime|-[Ss]hared[Rr]untime) + say_warning "The --shared-runtime flag is obsolete and may be removed in a future version of this script. The recommended usage is to specify '--runtime dotnet'." + if [ -z "$runtime" ]; then + runtime="dotnet" + fi + ;; + --runtime|-[Rr]untime) + shift + runtime="$1" + if [[ "$runtime" != "dotnet" ]] && [[ "$runtime" != "aspnetcore" ]]; then + say_err "Unsupported value for --runtime: '$1'. Valid values are 'dotnet' and 'aspnetcore'." + if [[ "$runtime" == "windowsdesktop" ]]; then + say_err "WindowsDesktop archives are manufactured for Windows platforms only." + fi + exit 1 + fi + ;; + --dry-run|-[Dd]ry[Rr]un) + dry_run=true + ;; + --no-path|-[Nn]o[Pp]ath) + no_path=true + non_dynamic_parameters+=" $name" + ;; + --verbose|-[Vv]erbose) + verbose=true + non_dynamic_parameters+=" $name" + ;; + --azure-feed|-[Aa]zure[Ff]eed) + shift + azure_feed="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + ;; + --uncached-feed|-[Uu]ncached[Ff]eed) + shift + uncached_feed="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + ;; + --feed-credential|-[Ff]eed[Cc]redential) + shift + feed_credential="$1" + #feed_credential should start with "?", for it to be added to the end of the link. + #adding "?" at the beginning of the feed_credential if needed. + [[ -z "$(echo $feed_credential)" ]] || [[ $feed_credential == \?* ]] || feed_credential="?$feed_credential" + ;; + --runtime-id|-[Rr]untime[Ii]d) + shift + runtime_id="$1" + non_dynamic_parameters+=" $name "\""$1"\""" + say_warning "Use of --runtime-id is obsolete and should be limited to the versions below 2.1. To override architecture, use --architecture option instead. To override OS, use --os option instead." + ;; + --jsonfile|-[Jj][Ss]on[Ff]ile) + shift + json_file="$1" + ;; + --skip-non-versioned-files|-[Ss]kip[Nn]on[Vv]ersioned[Ff]iles) + override_non_versioned_files=false + non_dynamic_parameters+=" $name" + ;; + --keep-zip|-[Kk]eep[Zz]ip) + keep_zip=true + non_dynamic_parameters+=" $name" + ;; + --zip-path|-[Zz]ip[Pp]ath) + shift + zip_path="$1" + ;; + -?|--?|-h|--help|-[Hh]elp) + script_name="dotnet-install.sh" + echo ".NET Tools Installer" + echo "Usage:" + echo " # Install a .NET SDK of a given Quality from a given Channel" + echo " $script_name [-c|--channel ] [-q|--quality ]" + echo " # Install a .NET SDK of a specific public version" + echo " $script_name [-v|--version ]" + echo " $script_name -h|-?|--help" + echo "" + echo "$script_name is a simple command line interface for obtaining dotnet cli." + echo " Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" + echo " - The SDK needs to be installed without user interaction and without admin rights." + echo " - The SDK installation doesn't need to persist across multiple CI runs." + echo " To set up a development environment or to run apps, use installers rather than this script. Visit https://dotnet.microsoft.com/download to get the installer." + echo "" + echo "Options:" + echo " -c,--channel Download from the channel specified, Defaults to \`$channel\`." + echo " -Channel" + echo " Possible values:" + echo " - STS - the most recent Standard Term Support release" + echo " - LTS - the most recent Long Term Support release" + echo " - 2-part version in a format A.B - represents a specific release" + echo " examples: 2.0; 1.0" + echo " - 3-part version in a format A.B.Cxx - represents a specific SDK release" + echo " examples: 5.0.1xx, 5.0.2xx." + echo " Supported since 5.0 release" + echo " Warning: Value 'Current' is deprecated for the Channel parameter. Use 'STS' instead." + echo " Note: The version parameter overrides the channel parameter when any version other than 'latest' is used." + echo " -v,--version Use specific VERSION, Defaults to \`$version\`." + echo " -Version" + echo " Possible values:" + echo " - latest - the latest build on specific channel" + echo " - 3-part version in a format A.B.C - represents specific version of build" + echo " examples: 2.0.0-preview2-006120; 1.1.0" + echo " -q,--quality Download the latest build of specified quality in the channel." + echo " -Quality" + echo " The possible values are: daily, preview, GA." + echo " Works only in combination with channel. Not applicable for STS and LTS channels and will be ignored if those channels are used." + echo " For SDK use channel in A.B.Cxx format. Using quality for SDK together with channel in A.B format is not supported." + echo " Supported since 5.0 release." + echo " Note: The version parameter overrides the channel parameter when any version other than 'latest' is used, and therefore overrides the quality." + echo " --internal,-Internal Download internal builds. Requires providing credentials via --feed-credential parameter." + echo " --feed-credential Token to access Azure feed. Used as a query string to append to the Azure feed." + echo " -FeedCredential This parameter typically is not specified." + echo " -i,--install-dir Install under specified location (see Install Location below)" + echo " -InstallDir" + echo " --architecture Architecture of dotnet binaries to be installed, Defaults to \`$architecture\`." + echo " --arch,-Architecture,-Arch" + echo " Possible values: x64, arm, arm64, s390x, ppc64le and loongarch64" + echo " --os Specifies operating system to be used when selecting the installer." + echo " Overrides the OS determination approach used by the script. Supported values: osx, linux, linux-musl, freebsd, rhel.6." + echo " In case any other value is provided, the platform will be determined by the script based on machine configuration." + echo " Not supported for legacy links. Use --runtime-id to specify platform for legacy links." + echo " Refer to: https://aka.ms/dotnet-os-lifecycle for more information." + echo " --runtime Installs a shared runtime only, without the SDK." + echo " -Runtime" + echo " Possible values:" + echo " - dotnet - the Microsoft.NETCore.App shared runtime" + echo " - aspnetcore - the Microsoft.AspNetCore.App shared runtime" + echo " --dry-run,-DryRun Do not perform installation. Display download link." + echo " --no-path, -NoPath Do not set PATH for the current process." + echo " --verbose,-Verbose Display diagnostics information." + echo " --azure-feed,-AzureFeed For internal use only." + echo " Allows using a different storage to download SDK archives from." + echo " --uncached-feed,-UncachedFeed For internal use only." + echo " Allows using a different storage to download SDK archives from." + echo " --skip-non-versioned-files Skips non-versioned files if they already exist, such as the dotnet executable." + echo " -SkipNonVersionedFiles" + echo " --jsonfile Determines the SDK version from a user specified global.json file." + echo " Note: global.json must have a value for 'SDK:Version'" + echo " --keep-zip,-KeepZip If set, downloaded file is kept." + echo " --zip-path, -ZipPath If set, downloaded file is stored at the specified path." + echo " -?,--?,-h,--help,-Help Shows this help message" + echo "" + echo "Install Location:" + echo " Location is chosen in following order:" + echo " - --install-dir option" + echo " - Environmental variable DOTNET_INSTALL_DIR" + echo " - $HOME/.dotnet" + exit 0 + ;; + *) + say_err "Unknown argument \`$name\`" + exit 1 + ;; + esac + + shift +done + +say_verbose "Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" +say_verbose "- The SDK needs to be installed without user interaction and without admin rights." +say_verbose "- The SDK installation doesn't need to persist across multiple CI runs." +say_verbose "To set up a development environment or to run apps, use installers rather than this script. Visit https://dotnet.microsoft.com/download to get the installer.\n" + +if [ "$internal" = true ] && [ -z "$(echo $feed_credential)" ]; then + message="Provide credentials via --feed-credential parameter." + if [ "$dry_run" = true ]; then + say_warning "$message" + else + say_err "$message" + exit 1 + fi +fi + +check_min_reqs +calculate_vars +# generate_regular_links call below will 'exit' if the determined version is already installed. +generate_download_links + +if [[ "$dry_run" = true ]]; then + print_dry_run + exit 0 +fi + +install_dotnet + +bin_path="$(get_absolute_path "$(combine_paths "$install_root" "$bin_folder_relative_path")")" +if [ "$no_path" = false ]; then + say "Adding to current process PATH: \`$bin_path\`. Note: This change will be visible only when sourcing script." + export PATH="$bin_path":"$PATH" +else + say "Binaries of dotnet can be found in $bin_path" +fi + +say "Note that the script does not resolve dependencies during installation." +say "To check the list of dependencies, go to https://learn.microsoft.com/dotnet/core/install, select your operating system and check the \"Dependencies\" section." +say "Installation finished successfully." diff --git a/src/NopCommerce.sln b/src/NopCommerce.sln index f329f9fa537..8242a72d5c9 100644 --- a/src/NopCommerce.sln +++ b/src/NopCommerce.sln @@ -71,6 +71,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nop.Plugin.Misc.OmnibusDire EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nop.Plugin.Widgets.AccessiBe", "Plugins\Nop.Plugin.Widgets.AccessiBe\Nop.Plugin.Widgets.AccessiBe.csproj", "{3BF04BB3-4BCB-0F3A-8230-EC1F27135D71}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nop.Plugin.Misc.SerialNumbers", "Plugins\Nop.Plugin.Misc.SerialNumbers\Nop.Plugin.Misc.SerialNumbers.csproj", "{C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -419,6 +421,18 @@ Global {3BF04BB3-4BCB-0F3A-8230-EC1F27135D71}.Release|Mixed Platforms.Build.0 = Release|Any CPU {3BF04BB3-4BCB-0F3A-8230-EC1F27135D71}.Release|x86.ActiveCfg = Release|Any CPU {3BF04BB3-4BCB-0F3A-8230-EC1F27135D71}.Release|x86.Build.0 = Release|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Debug|x86.ActiveCfg = Debug|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Debug|x86.Build.0 = Debug|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Release|Any CPU.Build.0 = Release|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Release|x86.ActiveCfg = Release|Any CPU + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -454,6 +468,7 @@ Global {D476A0EF-77AD-42C7-9288-69D417816FC7} = {7881B112-7843-4542-B1F7-F99553FB9BB7} {DA33F57C-2E56-4D63-B0D9-D87069D786B8} = {7881B112-7843-4542-B1F7-F99553FB9BB7} {3BF04BB3-4BCB-0F3A-8230-EC1F27135D71} = {7881B112-7843-4542-B1F7-F99553FB9BB7} + {C9B3D047-FAED-4CFD-5125-F4AAD6D3049B} = {7881B112-7843-4542-B1F7-F99553FB9BB7} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EE72A8B2-332A-4175-9319-6726D36E9D25} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Content/serial-numbers.css b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Content/serial-numbers.css new file mode 100644 index 00000000000..099fa46f5f4 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Content/serial-numbers.css @@ -0,0 +1,249 @@ +/* Serial Numbers Plugin Styles */ +.serial-numbers-config { + background: #f8f9fa; +} + +.serial-numbers-config .card { + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border: none; + border-radius: 8px; +} + +.serial-numbers-config .card-header { + background: linear-gradient(135deg, #007cba 0%, #0056b3 100%); + color: white; + border-radius: 8px 8px 0 0 !important; + padding: 1rem 1.5rem; + font-weight: 600; +} + +.serial-numbers-config .form-group { + margin-bottom: 1.5rem; +} + +.serial-numbers-config .form-group label { + font-weight: 600; + color: #495057; + margin-bottom: 0.5rem; +} + +.serial-numbers-config .form-control { + border-radius: 6px; + border: 1px solid #ced4da; + padding: 0.6rem 0.75rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.serial-numbers-config .form-control:focus { + border-color: #007cba; + box-shadow: 0 0 0 0.2rem rgba(0, 124, 186, 0.25); +} + +.serial-numbers-config .hint { + color: #6c757d; + font-size: 0.875rem; + margin-top: 0.25rem; + font-style: italic; +} + +.serial-numbers-config .btn-primary { + background: linear-gradient(135deg, #007cba 0%, #0056b3 100%); + border: none; + border-radius: 6px; + padding: 0.6rem 1.5rem; + font-weight: 600; + transition: all 0.15s ease-in-out; +} + +.serial-numbers-config .btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 124, 186, 0.3); +} + +.serial-numbers-config .input-group-btn .btn { + border-radius: 0 6px 6px 0; + border-left: none; +} + +.serial-numbers-config .api-key-group { + position: relative; +} + +.serial-numbers-config .api-key-toggle { + background: #f8f9fa; + border: 1px solid #ced4da; + border-left: none; + color: #495057; + transition: all 0.15s ease-in-out; +} + +.serial-numbers-config .api-key-toggle:hover { + background: #e9ecef; + color: #007cba; +} + +.serial-numbers-config .config-section { + background: white; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; + border-left: 4px solid #007cba; +} + +.serial-numbers-config .config-section h5 { + color: #007cba; + margin-bottom: 1rem; + font-weight: 600; +} + +.serial-numbers-page .serial-number-item { + border: 1px solid #e9ecef; + border-radius: 8px; + margin-bottom: 20px; + padding: 20px; + background: #fff; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + transition: all 0.15s ease-in-out; +} + +.serial-numbers-page .serial-number-item:hover { + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + transform: translateY(-1px); +} + +.serial-numbers-page .serial-number-item.inactive { + background: #f8f9fa; + border-color: #dee2e6; +} + +.serial-numbers-page .serial-number-item.expired { + background: #fff5f5; + border-color: #f56565; + border-left: 4px solid #f56565; +} + +.serial-numbers-page .serial-number-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + border-bottom: 1px solid #e9ecef; + padding-bottom: 10px; +} + +.serial-numbers-page .product-name { + margin: 0; + color: #2d3748; + font-size: 1.25rem; + font-weight: 600; +} + +.serial-numbers-page .serial-number-status .status-active { + color: #38a169; + font-weight: 600; + background: #f0fff4; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.875rem; +} + +.serial-numbers-page .serial-number-status .status-inactive { + color: #d69e2e; + font-weight: 600; + background: #fffbf0; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.875rem; +} + +.serial-numbers-page .serial-number-status .status-expired { + color: #e53e3e; + font-weight: 600; + background: #fff5f5; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.875rem; +} + +.serial-numbers-page .info-row { + display: flex; + margin-bottom: 12px; + align-items: center; +} + +.serial-numbers-page .info-row .label { + font-weight: 600; + min-width: 140px; + margin-right: 15px; + color: #4a5568; +} + +.serial-numbers-page .info-row .value { + flex: 1; + color: #2d3748; +} + +.serial-numbers-page .serial-code { + font-family: 'Courier New', monospace; + background: #f7fafc; + padding: 6px 12px; + border-radius: 6px; + font-size: 0.9em; + border: 1px solid #e2e8f0; + color: #2d3748; + font-weight: 500; +} + +.serial-numbers-page .remaining { + color: #38a169; + font-size: 0.9em; + font-weight: 600; +} + +.serial-numbers-page .serial-number-notes { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #e9ecef; + background: #f8f9fa; + padding: 15px; + border-radius: 6px; + color: #6c757d; +} + +.serial-numbers-page .serial-number-actions { + margin-top: 15px; + text-align: right; +} + +.serial-numbers-page .pager { + text-align: center; + margin-top: 30px; +} + +.serial-numbers-page .pager a, +.serial-numbers-page .pager .current-page { + display: inline-block; + padding: 10px 16px; + margin: 0 5px; + text-decoration: none; + border-radius: 6px; + transition: all 0.15s ease-in-out; +} + +.serial-numbers-page .pager a { + background: #f8f9fa; + color: #495057; + border: 1px solid #dee2e6; +} + +.serial-numbers-page .pager a:hover { + background: #e9ecef; + color: #007cba; + transform: translateY(-1px); +} + +.serial-numbers-page .pager .current-page { + background: linear-gradient(135deg, #007cba 0%, #0056b3 100%); + color: white; + border: 1px solid #007cba; +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/CustomerSerialNumbersController.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/CustomerSerialNumbersController.cs new file mode 100644 index 00000000000..5c2c9a3584c --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/CustomerSerialNumbersController.cs @@ -0,0 +1,183 @@ +using Microsoft.AspNetCore.Mvc; +using Nop.Core; +using Nop.Plugin.Misc.SerialNumbers.Models.Customer; +using Nop.Plugin.Misc.SerialNumbers.Services; +using Nop.Services.Catalog; +using Nop.Services.Customers; +using Nop.Services.Localization; +using Nop.Services.Orders; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.SerialNumbers.Controllers; + +/// +/// Customer-facing controller for serial numbers +/// +public class CustomerSerialNumbersController : BaseController +{ + #region Fields + + private readonly ISoftwareSerialNumberService _serialNumberService; + private readonly ISoftwareInstallationService _installationService; + private readonly ICustomerService _customerService; + private readonly IProductService _productService; + private readonly IOrderService _orderService; + private readonly IWorkContext _workContext; + private readonly ILocalizationService _localizationService; + private readonly SerialNumberSettings _serialNumberSettings; + + #endregion + + #region Ctor + + public CustomerSerialNumbersController( + ISoftwareSerialNumberService serialNumberService, + ISoftwareInstallationService installationService, + ICustomerService customerService, + IProductService productService, + IOrderService orderService, + IWorkContext workContext, + ILocalizationService localizationService, + SerialNumberSettings serialNumberSettings) + { + _serialNumberService = serialNumberService; + _installationService = installationService; + _customerService = customerService; + _productService = productService; + _orderService = orderService; + _workContext = workContext; + _localizationService = localizationService; + _serialNumberSettings = serialNumberSettings; + } + + #endregion + + #region Methods + + /// + /// Customer serial numbers list + /// + /// Page index + /// Serial numbers view + [HttpGet] + public async Task SerialNumbers(int page = 1) + { + if (!_serialNumberSettings.AllowCustomersToViewSerialNumbers) + return RedirectToRoute("CustomerInfo"); + + var customer = await _workContext.GetCurrentCustomerAsync(); + const int pageSize = 10; + + var serialNumbers = await _serialNumberService.GetCustomerSerialNumbersAsync( + customer.Id, + page - 1, + pageSize); + + var model = new CustomerSerialNumbersModel + { + PageIndex = page - 1, + PageSize = pageSize, + TotalCount = serialNumbers.TotalCount + }; + + foreach (var sn in serialNumbers) + { + var product = await _productService.GetProductByIdAsync(sn.ProductId); + var order = await _orderService.GetOrderByIdAsync(sn.OrderId); + + var serialNumberModel = new SerialNumberModel + { + Id = sn.Id, + SerialNumber = sn.SerialNumber, + ProductName = product?.Name ?? "Unknown Product", + ProductId = sn.ProductId, + OrderId = sn.OrderId, + OrderNumber = order?.CustomOrderNumber ?? sn.OrderId.ToString(), + IsActive = sn.IsActive, + MaxInstallations = sn.MaxInstallations, + CurrentInstallations = sn.CurrentInstallations, + CreatedOn = sn.CreatedOnUtc, + ExpiresOn = sn.ExpiresOnUtc, + Notes = sn.Notes + }; + + model.SerialNumbers.Add(serialNumberModel); + } + + return View("~/Plugins/Misc.SerialNumbers/Views/CustomerSerialNumbers/SerialNumbers.cshtml", model); + } + + /// + /// Installation details for a serial number + /// + /// Serial number ID + /// Installation details view + [HttpGet] + public async Task Installations(int id) + { + if (!_serialNumberSettings.AllowCustomersToViewInstallations) + return RedirectToAction("SerialNumbers"); + + var customer = await _workContext.GetCurrentCustomerAsync(); + var serialNumber = await _serialNumberService.GetSoftwareSerialNumberByIdAsync(id); + + if (serialNumber == null || serialNumber.CustomerId != customer.Id) + return RedirectToAction("SerialNumbers"); + + var product = await _productService.GetProductByIdAsync(serialNumber.ProductId); + var order = await _orderService.GetOrderByIdAsync(serialNumber.OrderId); + + var serialNumberModel = new SerialNumberModel + { + Id = serialNumber.Id, + SerialNumber = serialNumber.SerialNumber, + ProductName = product?.Name ?? "Unknown Product", + ProductId = serialNumber.ProductId, + OrderId = serialNumber.OrderId, + OrderNumber = order?.CustomOrderNumber ?? serialNumber.OrderId.ToString(), + IsActive = serialNumber.IsActive, + MaxInstallations = serialNumber.MaxInstallations, + CurrentInstallations = serialNumber.CurrentInstallations, + CreatedOn = serialNumber.CreatedOnUtc, + ExpiresOn = serialNumber.ExpiresOnUtc, + Notes = serialNumber.Notes + }; + + var installations = await _installationService.GetInstallationsBySerialNumberIdAsync(serialNumber.Id); + var installationModels = new List(); + + foreach (var installation in installations) + { + var installationModel = new InstallationModel + { + Id = installation.Id, + MachineId = installation.MachineId, + ComputerName = installation.ComputerName, + OperatingSystem = installation.OperatingSystem, + ProcessorInfo = installation.ProcessorInfo, + TotalMemoryFormatted = installation.TotalMemory.HasValue + ? $"{installation.TotalMemory.Value / (1024 * 1024 * 1024):F1} GB" + : null, + SoftwareVersion = installation.SoftwareVersion, + InstallationPath = installation.InstallationPath, + IPAddress = installation.IPAddress, + IsActive = installation.IsActive, + InstalledOn = installation.InstalledOnUtc, + LastVerified = installation.LastVerifiedOnUtc + }; + + installationModels.Add(installationModel); + } + + var model = new InstallationDetailsModel + { + SerialNumber = serialNumberModel, + Installations = installationModels + }; + + return View("~/Plugins/Misc.SerialNumbers/Views/CustomerSerialNumbers/Installations.cshtml", model); + } + + #endregion +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/SerialNumberAdminController.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/SerialNumberAdminController.cs new file mode 100644 index 00000000000..5357f334b0d --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/SerialNumberAdminController.cs @@ -0,0 +1,110 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Nop.Data; +using Nop.Plugin.Misc.SerialNumbers.Domain; +using Nop.Plugin.Misc.SerialNumbers.Models.Admin; +using Nop.Plugin.Misc.SerialNumbers.Services; +using Nop.Services.Localization; +using Nop.Services.Security; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.SerialNumbers.Controllers +{ + [AuthorizeAdmin] + public class SerialNumberAdminController : BasePluginController + { + private readonly IRepository _maskRepository; + private readonly ILocalizationService _localizationService; + private readonly IPermissionService _permissionService; + + public SerialNumberAdminController( + IRepository maskRepository, + ILocalizationService localizationService, + IPermissionService permissionService) + { + _maskRepository = maskRepository; + _localizationService = localizationService; + _permissionService = permissionService; + } + + public async Task List() + { + var masks = await _maskRepository.GetAllAsync(q => q); + var model = masks.Select(m => new SerialNumberMaskModel + { + Id = m.Id, + Mask = m.Mask, + Counter = m.Counter, + ProductId = m.ProductId, + IsGlobal = m.IsGlobal, + CreatedOnUtc = m.CreatedOnUtc + }).ToList(); + return View(model); + } + + public IActionResult Create() + { + return View(new SerialNumberMaskModel()); + } + + [HttpPost] + public async Task Create(SerialNumberMaskModel model) + { + if (!ModelState.IsValid) + return View(model); + var entity = new SerialNumberMask + { + Mask = model.Mask, + Counter = model.Counter, + ProductId = model.ProductId, + IsGlobal = model.IsGlobal, + CreatedOnUtc = DateTime.UtcNow + }; + await _maskRepository.InsertAsync(entity); + return RedirectToAction("List"); + } + + public async Task Edit(int id) + { + var entity = await _maskRepository.GetByIdAsync(id); + if (entity == null) + return RedirectToAction("List"); + var model = new SerialNumberMaskModel + { + Id = entity.Id, + Mask = entity.Mask, + Counter = entity.Counter, + ProductId = entity.ProductId, + IsGlobal = entity.IsGlobal, + CreatedOnUtc = entity.CreatedOnUtc + }; + return View(model); + } + + [HttpPost] + public async Task Edit(SerialNumberMaskModel model) + { + if (!ModelState.IsValid) + return View(model); + var entity = await _maskRepository.GetByIdAsync(model.Id); + if (entity == null) + return RedirectToAction("List"); + entity.Mask = model.Mask; + entity.Counter = model.Counter; + entity.ProductId = model.ProductId; + entity.IsGlobal = model.IsGlobal; + await _maskRepository.UpdateAsync(entity); + return RedirectToAction("List"); + } + + public async Task Delete(int id) + { + var entity = await _maskRepository.GetByIdAsync(id); + if (entity != null) + await _maskRepository.DeleteAsync(entity); + return RedirectToAction("List"); + } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/SerialNumberApiController.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/SerialNumberApiController.cs new file mode 100644 index 00000000000..cb748e61d66 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/SerialNumberApiController.cs @@ -0,0 +1,422 @@ +using Microsoft.AspNetCore.Mvc; +using Nop.Plugin.Misc.SerialNumbers.Models.Api; +using Nop.Plugin.Misc.SerialNumbers.Services; +using Nop.Web.Framework.Controllers; + +namespace Nop.Plugin.Misc.SerialNumbers.Controllers; + +/// +/// API controller for Advanced Installer server-side validation +/// +[Route("api/serialnumbers")] +[ApiController] +public class SerialNumberApiController : BaseController +{ + #region Fields + + private readonly ISoftwareSerialNumberService _serialNumberService; + private readonly ISoftwareInstallationService _installationService; + private readonly SerialNumberSettings _serialNumberSettings; + + #endregion + + #region Ctor + + public SerialNumberApiController( + ISoftwareSerialNumberService serialNumberService, + ISoftwareInstallationService installationService, + SerialNumberSettings serialNumberSettings) + { + _serialNumberService = serialNumberService; + _installationService = installationService; + _serialNumberSettings = serialNumberSettings; + } + + #endregion + + #region Methods + + /// + /// Validates a serial number for Advanced Installer + /// + /// The validation request + /// The validation response + [HttpPost("validate")] + public async Task ValidateSerialNumber([FromBody] SerialNumberValidationRequest request) + { + try + { + // Validate API key if required + if (_serialNumberSettings.RequireApiKey) + { + if (string.IsNullOrEmpty(request.ApiKey) || request.ApiKey != _serialNumberSettings.ApiKey) + { + return Unauthorized(new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "Invalid or missing API key" + }); + } + } + + // Validate input + if (string.IsNullOrEmpty(request.SerialNumber)) + { + return BadRequest(new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "Serial number is required" + }); + } + + // Perform validation + var validationResult = await _serialNumberService.ValidateSerialNumberAsync( + request.SerialNumber, + request.MachineId); + + var response = new SerialNumberValidationResponse + { + Success = validationResult.IsValid, + AllowInstallation = validationResult.CanInstall, + Message = validationResult.IsValid ? + (validationResult.CanInstall ? "Validation successful" : validationResult.ErrorMessage) : + validationResult.ErrorMessage, + RemainingInstallations = validationResult.RemainingInstallations + }; + + // Add additional information if validation was successful + if (validationResult.IsValid) + { + var serialNumber = await _serialNumberService.GetSoftwareSerialNumberByCodeAsync(request.SerialNumber); + if (serialNumber != null) + { + response.MaxInstallations = serialNumber.MaxInstallations; + response.ExpirationDate = serialNumber.ExpiresOnUtc; + } + } + + return Ok(response); + } + catch (Exception ex) + { + return StatusCode(500, new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "An error occurred during validation" + }); + } + } + /// + /// Advanced Installer server-side serial validation (form POST, svar enligt AI-spec) + /// + [HttpPost("ai-validate")] + [IgnoreAntiforgeryToken] + public async Task AdvancedInstallerValidate() + { + // Fält enligt Advanced Installer-dokumentation + var form = Request.HasFormContentType ? Request.Form : null; + string serial = form?["sn"]; + string username = form?["username"]; + string company = form?["company"]; + string email = form?["email"]; + string version = form?["version"]; + string languageid = form?["languageid"]; + string additionalInfo = form?["ai"]; + + // Ingen API-nyckel krävs för denna endpoint enligt senaste instruktion + + // Kontrollera serial + if (string.IsNullOrEmpty(serial)) + return Content("602\nMissing Serial Number!", "text/plain"); + + // Validera mot din tjänst + var validationResult = await _serialNumberService.ValidateSerialNumberAsync(serial, null); + if (!validationResult.IsValid) + return Content($"602\n{validationResult.ErrorMessage ?? "Serial Number is invalid!"}", "text/plain"); + + // Spara så mycket info som möjligt (om du vill logga eller koppla till användare) + // Exempel: skapa/uppdatera kund, logga validering, etc. + // Du kan använda username, company, email, version, languageid, additionalInfo + // ... + + // Om du vill begränsa antal aktiveringar, kontrollera det här och returnera 602 om max är nått + if (!validationResult.CanInstall) + return Content($"602\n{validationResult.ErrorMessage ?? "Maximum number of validations exceeded"}", "text/plain"); + + // Allt OK + return Content("601", "text/plain"); + } + /// + /// Registers a software installation + /// + /// The registration request + /// The registration response + [HttpPost("register")] + public async Task RegisterInstallation([FromBody] InstallationRegistrationRequest request) + { + try + { + // Validate API key if required + if (_serialNumberSettings.RequireApiKey) + { + if (string.IsNullOrEmpty(request.ApiKey) || request.ApiKey != _serialNumberSettings.ApiKey) + { + return Unauthorized(new InstallationRegistrationResponse + { + Success = false, + AllowInstallation = false, + Message = "Invalid or missing API key" + }); + } + } + + // Validate input + if (string.IsNullOrEmpty(request.SerialNumber)) + { + return BadRequest(new InstallationRegistrationResponse + { + Success = false, + AllowInstallation = false, + Message = "Serial number is required" + }); + } + + if (string.IsNullOrEmpty(request.MachineId)) + { + return BadRequest(new InstallationRegistrationResponse + { + Success = false, + AllowInstallation = false, + Message = "Machine ID is required" + }); + } + + // First validate the serial number + var validationResult = await _serialNumberService.ValidateSerialNumberAsync( + request.SerialNumber, + request.MachineId); + + if (!validationResult.IsValid) + { + return BadRequest(new InstallationRegistrationResponse + { + Success = false, + AllowInstallation = false, + Message = validationResult.ErrorMessage + }); + } + + if (!validationResult.CanInstall && !request.ForceRegistration) + { + return BadRequest(new InstallationRegistrationResponse + { + Success = false, + AllowInstallation = false, + Message = validationResult.ErrorMessage + }); + } + + // Get the serial number record + var serialNumber = await _serialNumberService.GetSoftwareSerialNumberByCodeAsync(request.SerialNumber); + if (serialNumber == null) + { + return BadRequest(new InstallationRegistrationResponse + { + Success = false, + AllowInstallation = false, + Message = "Invalid serial number" + }); + } + + // Register the installation + var installation = await _installationService.RegisterInstallationAsync( + serialNumber.Id, + request.MachineId, + request.ComputerName, + request.OperatingSystem, + request.ProcessorInfo, + request.TotalMemory, + request.SoftwareVersion, + request.InstallationPath, + HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + request.AdditionalData); + + var response = new InstallationRegistrationResponse + { + Success = true, + AllowInstallation = true, + Message = "Installation registered successfully", + InstallationId = installation.Id, + RegistrationDate = installation.InstalledOnUtc, + RemainingInstallations = Math.Max(0, serialNumber.MaxInstallations - serialNumber.CurrentInstallations - 1), + MaxInstallations = serialNumber.MaxInstallations, + ExpirationDate = serialNumber.ExpiresOnUtc + }; + + return Ok(response); + } + catch (Exception ex) + { + return StatusCode(500, new InstallationRegistrationResponse + { + Success = false, + AllowInstallation = false, + Message = "An error occurred during registration" + }); + } + } + + /// + /// Verifies an existing installation + /// + /// The serial number + /// The machine ID + /// The API key (if required) + /// The verification response + [HttpGet("verify")] + public async Task VerifyInstallation([FromQuery] string serialNumber, [FromQuery] string machineId, [FromQuery] string apiKey = null) + { + try + { + // Validate API key if required + if (_serialNumberSettings.RequireApiKey) + { + if (string.IsNullOrEmpty(apiKey) || apiKey != _serialNumberSettings.ApiKey) + { + return Unauthorized(new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "Invalid or missing API key" + }); + } + } + + // Validate input + if (string.IsNullOrEmpty(serialNumber) || string.IsNullOrEmpty(machineId)) + { + return BadRequest(new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "Serial number and machine ID are required" + }); + } + + // Get the serial number record + var snRecord = await _serialNumberService.GetSoftwareSerialNumberByCodeAsync(serialNumber); + if (snRecord == null) + { + return NotFound(new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "Serial number not found" + }); + } + + // Verify the installation + var verified = await _installationService.VerifyInstallationAsync(snRecord.Id, machineId); + + var response = new SerialNumberValidationResponse + { + Success = verified, + AllowInstallation = verified, + Message = verified ? "Installation verified successfully" : "Installation not found or inactive", + RemainingInstallations = Math.Max(0, snRecord.MaxInstallations - snRecord.CurrentInstallations), + MaxInstallations = snRecord.MaxInstallations, + ExpirationDate = snRecord.ExpiresOnUtc + }; + + return Ok(response); + } + catch (Exception ex) + { + return StatusCode(500, new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "An error occurred during verification" + }); + } + } + + /// + /// Deactivates an installation + /// + /// The deactivation request + /// The deactivation response + [HttpPost("deactivate")] + public async Task DeactivateInstallation([FromBody] SerialNumberValidationRequest request) + { + try + { + // Validate API key if required + if (_serialNumberSettings.RequireApiKey) + { + if (string.IsNullOrEmpty(request.ApiKey) || request.ApiKey != _serialNumberSettings.ApiKey) + { + return Unauthorized(new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "Invalid or missing API key" + }); + } + } + + // Validate input + if (string.IsNullOrEmpty(request.SerialNumber) || string.IsNullOrEmpty(request.MachineId)) + { + return BadRequest(new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "Serial number and machine ID are required" + }); + } + + // Get the serial number record + var snRecord = await _serialNumberService.GetSoftwareSerialNumberByCodeAsync(request.SerialNumber); + if (snRecord == null) + { + return NotFound(new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "Serial number not found" + }); + } + + // Deactivate the installation + var deactivated = await _installationService.DeactivateInstallationAsync(snRecord.Id, request.MachineId); + + var response = new SerialNumberValidationResponse + { + Success = deactivated, + AllowInstallation = false, + Message = deactivated ? "Installation deactivated successfully" : "Installation not found", + RemainingInstallations = Math.Max(0, snRecord.MaxInstallations - snRecord.CurrentInstallations + (deactivated ? 1 : 0)), + MaxInstallations = snRecord.MaxInstallations, + ExpirationDate = snRecord.ExpiresOnUtc + }; + + return Ok(response); + } + catch (Exception ex) + { + return StatusCode(500, new SerialNumberValidationResponse + { + Success = false, + AllowInstallation = false, + Message = "An error occurred during deactivation" + }); + } + } + + #endregion +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/SerialNumbersController.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/SerialNumbersController.cs new file mode 100644 index 00000000000..22dc6762754 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Controllers/SerialNumbersController.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Mvc; +using Nop.Services.Configuration; +using Nop.Services.Localization; +using Nop.Services.Messages; +using Nop.Services.Security; +using Nop.Web.Framework; +using Nop.Web.Framework.Controllers; +using Nop.Web.Framework.Mvc.Filters; + +namespace Nop.Plugin.Misc.SerialNumbers.Controllers; + +[Area(AreaNames.ADMIN)] +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +public class SerialNumbersController : BasePluginController +{ + private readonly ISettingService _settingService; + private readonly INotificationService _notificationService; + private readonly ILocalizationService _localizationService; + + public SerialNumbersController( + ISettingService settingService, + INotificationService notificationService, + ILocalizationService localizationService) + { + _settingService = settingService; + _notificationService = notificationService; + _localizationService = localizationService; + } + + [CheckPermission(StandardPermission.Configuration.MANAGE_PLUGINS)] + public async Task Configure() + { + var settings = await _settingService.LoadSettingAsync(); + + var model = new Models.SerialNumberSettingsModel + { + GenerateSerialNumbersOnPayment = settings.GenerateSerialNumbersOnPayment, + GenerateForAllProducts = settings.GenerateForAllProducts, + DefaultMaxInstallations = settings.DefaultMaxInstallations, + DefaultExpirationDays = settings.DefaultExpirationDays, + AllowCustomersToViewSerialNumbers = settings.AllowCustomersToViewSerialNumbers, + AllowCustomersToViewInstallations = settings.AllowCustomersToViewInstallations, + ApiKey = settings.ApiKey, + RequireApiKey = settings.RequireApiKey, + LogValidationRequests = settings.LogValidationRequests + }; + return View("~/Plugins/Misc.SerialNumbers/Views/Configure.cshtml", model); + } + + [HttpPost, ActionName("Configure")] + [FormValueRequired("save")] + [CheckPermission(StandardPermission.Configuration.MANAGE_PLUGINS)] + public async Task Configure(Models.SerialNumberSettingsModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var settings = await _settingService.LoadSettingAsync(); + + settings.GenerateSerialNumbersOnPayment = model.GenerateSerialNumbersOnPayment; + settings.GenerateForAllProducts = model.GenerateForAllProducts; + settings.DefaultMaxInstallations = model.DefaultMaxInstallations; + settings.DefaultExpirationDays = model.DefaultExpirationDays; + settings.AllowCustomersToViewSerialNumbers = model.AllowCustomersToViewSerialNumbers; + settings.AllowCustomersToViewInstallations = model.AllowCustomersToViewInstallations; + settings.ApiKey = model.ApiKey; + settings.RequireApiKey = model.RequireApiKey; + settings.LogValidationRequests = model.LogValidationRequests; + + await _settingService.SaveSettingAsync(settings); + + _notificationService.SuccessNotification(await _localizationService.GetResourceAsync("Admin.Plugins.Saved")); + + return await Configure(); + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/Migrations/CreateSerialNumberMaskTableMigration.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/Migrations/CreateSerialNumberMaskTableMigration.cs new file mode 100644 index 00000000000..a97c436d6b7 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/Migrations/CreateSerialNumberMaskTableMigration.cs @@ -0,0 +1,21 @@ +using FluentMigrator; +using Nop.Data.Extensions; +using Nop.Data.Migrations; +using Nop.Plugin.Misc.SerialNumbers.Domain; + +namespace Nop.Plugin.Misc.SerialNumbers.Data.Migrations +{ + [NopMigration("2025-09-05 01:00:00", "SerialNumbers: Create SerialNumberMask table", MigrationProcessType.Installation)] + public class CreateSerialNumberMaskTableMigration : Migration + { + public override void Up() + { + Create.TableFor(); + } + + public override void Down() + { + Delete.Table(nameof(SerialNumberMask)); + } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/Migrations/CreateSerialNumberTableMigration.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/Migrations/CreateSerialNumberTableMigration.cs new file mode 100644 index 00000000000..2809971cb4b --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/Migrations/CreateSerialNumberTableMigration.cs @@ -0,0 +1,21 @@ +using FluentMigrator; +using Nop.Data.Extensions; +using Nop.Data.Migrations; +using Nop.Plugin.Misc.SerialNumbers.Domain; + +namespace Nop.Plugin.Misc.SerialNumbers.Data.Migrations +{ + [NopMigration("2025-09-05 01:01:00", "SerialNumbers: Create SerialNumber table", MigrationProcessType.Installation)] + public class CreateSerialNumberTableMigration : Migration + { + public override void Up() + { + Create.TableFor(); + } + + public override void Down() + { + Delete.Table(nameof(SerialNumber)); + } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/Migrations/CreateSerialNumberValidationLogTableMigration.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/Migrations/CreateSerialNumberValidationLogTableMigration.cs new file mode 100644 index 00000000000..5d60c1d843b --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/Migrations/CreateSerialNumberValidationLogTableMigration.cs @@ -0,0 +1,21 @@ +using FluentMigrator; +using Nop.Data.Extensions; +using Nop.Data.Migrations; +using Nop.Plugin.Misc.SerialNumbers.Domain; + +namespace Nop.Plugin.Misc.SerialNumbers.Data.Migrations +{ + [NopMigration("2025-09-05 01:02:00", "SerialNumbers: Create SerialNumberValidationLog table", MigrationProcessType.Installation)] + public class CreateSerialNumberValidationLogTableMigration : Migration + { + public override void Up() + { + Create.TableFor(); + } + + public override void Down() + { + Delete.Table(nameof(SerialNumberValidationLog)); + } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/SoftwareInstallationBuilder.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/SoftwareInstallationBuilder.cs new file mode 100644 index 00000000000..cc488f624a8 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/SoftwareInstallationBuilder.cs @@ -0,0 +1,65 @@ +using FluentMigrator.Builders.Create.Table; +using Nop.Data.Extensions; +using Nop.Data.Mapping.Builders; +using Nop.Plugin.Misc.SerialNumbers.Domain; + +namespace Nop.Plugin.Misc.SerialNumbers.Data; + +/// +/// Entity builder for the SoftwareInstallation entity +/// +public class SoftwareInstallationBuilder : NopEntityBuilder +{ + /// + /// Apply entity configuration + /// + /// Create table expression builder + public override void MapEntity(CreateTableExpressionBuilder table) + { + table + .WithColumn(nameof(SoftwareInstallation.SerialNumberId)) + .AsInt32() + .NotNullable() + .ForeignKey() + .WithColumn(nameof(SoftwareInstallation.MachineId)) + .AsString(200) + .NotNullable() + .WithColumn(nameof(SoftwareInstallation.ComputerName)) + .AsString(100) + .Nullable() + .WithColumn(nameof(SoftwareInstallation.OperatingSystem)) + .AsString(200) + .Nullable() + .WithColumn(nameof(SoftwareInstallation.ProcessorInfo)) + .AsString(500) + .Nullable() + .WithColumn(nameof(SoftwareInstallation.TotalMemory)) + .AsInt64() + .Nullable() + .WithColumn(nameof(SoftwareInstallation.SoftwareVersion)) + .AsString(50) + .Nullable() + .WithColumn(nameof(SoftwareInstallation.InstallationPath)) + .AsString(500) + .Nullable() + .WithColumn(nameof(SoftwareInstallation.IPAddress)) + .AsString(45) + .Nullable() + .WithColumn(nameof(SoftwareInstallation.UserAgent)) + .AsString(1000) + .Nullable() + .WithColumn(nameof(SoftwareInstallation.IsActive)) + .AsBoolean() + .NotNullable() + .WithDefaultValue(true) + .WithColumn(nameof(SoftwareInstallation.InstalledOnUtc)) + .AsDateTime() + .NotNullable() + .WithColumn(nameof(SoftwareInstallation.LastVerifiedOnUtc)) + .AsDateTime() + .Nullable() + .WithColumn(nameof(SoftwareInstallation.AdditionalData)) + .AsString(2000) + .Nullable(); + } +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/SoftwareSerialNumberBuilder.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/SoftwareSerialNumberBuilder.cs new file mode 100644 index 00000000000..fcc233cde6d --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Data/SoftwareSerialNumberBuilder.cs @@ -0,0 +1,63 @@ +using FluentMigrator.Builders.Create.Table; +using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Orders; +using Nop.Data.Extensions; +using Nop.Data.Mapping.Builders; +using Nop.Plugin.Misc.SerialNumbers.Domain; + +namespace Nop.Plugin.Misc.SerialNumbers.Data; + +/// +/// Entity builder for the SoftwareSerialNumber entity +/// +public class SoftwareSerialNumberBuilder : NopEntityBuilder +{ + /// + /// Apply entity configuration + /// + /// Create table expression builder + public override void MapEntity(CreateTableExpressionBuilder table) + { + table + .WithColumn(nameof(SoftwareSerialNumber.SerialNumber)) + .AsString(100) + .NotNullable() + .Unique("AK_SoftwareSerialNumbers_SerialNumber") + .WithColumn(nameof(SoftwareSerialNumber.OrderId)) + .AsInt32() + .NotNullable() + .ForeignKey() + .WithColumn(nameof(SoftwareSerialNumber.CustomerId)) + .AsInt32() + .NotNullable() + .ForeignKey() + .WithColumn(nameof(SoftwareSerialNumber.ProductId)) + .AsInt32() + .NotNullable() + .WithColumn(nameof(SoftwareSerialNumber.OrderItemId)) + .AsInt32() + .NotNullable() + .ForeignKey() + .WithColumn(nameof(SoftwareSerialNumber.IsActive)) + .AsBoolean() + .NotNullable() + .WithDefaultValue(true) + .WithColumn(nameof(SoftwareSerialNumber.MaxInstallations)) + .AsInt32() + .NotNullable() + .WithDefaultValue(1) + .WithColumn(nameof(SoftwareSerialNumber.CurrentInstallations)) + .AsInt32() + .NotNullable() + .WithDefaultValue(0) + .WithColumn(nameof(SoftwareSerialNumber.CreatedOnUtc)) + .AsDateTime() + .NotNullable() + .WithColumn(nameof(SoftwareSerialNumber.ExpiresOnUtc)) + .AsDateTime() + .Nullable() + .WithColumn(nameof(SoftwareSerialNumber.Notes)) + .AsString(1000) + .Nullable(); + } +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/Order.PluginPartial.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/Order.PluginPartial.cs new file mode 100644 index 00000000000..5ac7ef0a09c --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/Order.PluginPartial.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Nop.Core.Domain.Orders; + +namespace Nop.Plugin.Misc.SerialNumbers.Domain +{ + public partial class Order + { + /// + /// Gets or sets the order items (navigation property for plugin use) + /// + public virtual ICollection OrderItems { get; set; } = new List(); + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SerialNumber.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SerialNumber.cs new file mode 100644 index 00000000000..5c50772a986 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SerialNumber.cs @@ -0,0 +1,18 @@ +using System; +using Nop.Core; + +namespace Nop.Plugin.Misc.SerialNumbers.Domain +{ + /// + /// Entity for generated serial numbers + /// + public class SerialNumber : BaseEntity + { + public int OrderId { get; set; } + public int OrderItemId { get; set; } + public int CustomerId { get; set; } + public int MaskId { get; set; } + public string Serial { get; set; } + public DateTime CreatedOnUtc { get; set; } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SerialNumberMask.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SerialNumberMask.cs new file mode 100644 index 00000000000..566b61d0465 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SerialNumberMask.cs @@ -0,0 +1,18 @@ +using System; +using Nop.Core; + +namespace Nop.Plugin.Misc.SerialNumbers.Domain +{ + /// + /// Entity for serial number mask and counter + /// + public class SerialNumberMask : BaseEntity + { + public string Mask { get; set; } + public int Counter { get; set; } + public int? ProductId { get; set; } // null = global + public bool IsGlobal { get; set; } + public DateTime CreatedOnUtc { get; set; } + public DateTime? UpdatedOnUtc { get; set; } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SerialNumberValidationLog.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SerialNumberValidationLog.cs new file mode 100644 index 00000000000..6364afde353 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SerialNumberValidationLog.cs @@ -0,0 +1,41 @@ +using System; +using Nop.Core; + +namespace Nop.Plugin.Misc.SerialNumbers.Domain +{ + /// + /// Entity for logging serial number validation attempts + /// + public class SerialNumberValidationLog : BaseEntity + { + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.MaxLength(200)] + public string SerialNumber { get; set; } + + [System.ComponentModel.DataAnnotations.MaxLength(200)] + public string Username { get; set; } + + [System.ComponentModel.DataAnnotations.MaxLength(200)] + public string Company { get; set; } + + [System.ComponentModel.DataAnnotations.MaxLength(200)] + public string Email { get; set; } + + [System.ComponentModel.DataAnnotations.MaxLength(50)] + public string Version { get; set; } + + [System.ComponentModel.DataAnnotations.MaxLength(20)] + public string LanguageId { get; set; } + + [System.ComponentModel.DataAnnotations.MaxLength(1000)] + public string AdditionalInfo { get; set; } + + public bool IsValid { get; set; } + + [System.ComponentModel.DataAnnotations.MaxLength(1000)] + public string ErrorMessage { get; set; } + + [System.ComponentModel.DataAnnotations.Required] + public DateTime CreatedOnUtc { get; set; } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SoftwareInstallation.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SoftwareInstallation.cs new file mode 100644 index 00000000000..1bd208b5e49 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SoftwareInstallation.cs @@ -0,0 +1,79 @@ +using Nop.Core; + +namespace Nop.Plugin.Misc.SerialNumbers.Domain; + +/// +/// Represents a software installation record +/// +public partial class SoftwareInstallation : BaseEntity +{ + /// + /// Gets or sets the serial number identifier + /// + public int SerialNumberId { get; set; } + + /// + /// Gets or sets the computer/machine identifier + /// + public string MachineId { get; set; } + + /// + /// Gets or sets the computer name + /// + public string ComputerName { get; set; } + + /// + /// Gets or sets the operating system information + /// + public string OperatingSystem { get; set; } + + /// + /// Gets or sets the processor information + /// + public string ProcessorInfo { get; set; } + + /// + /// Gets or sets the total physical memory + /// + public long? TotalMemory { get; set; } + + /// + /// Gets or sets the software version being installed + /// + public string SoftwareVersion { get; set; } + + /// + /// Gets or sets the installation path + /// + public string InstallationPath { get; set; } + + /// + /// Gets or sets the IP address of the installing machine + /// + public string IPAddress { get; set; } + + /// + /// Gets or sets the user agent string + /// + public string UserAgent { get; set; } + + /// + /// Gets or sets a value indicating whether the installation is still active + /// + public bool IsActive { get; set; } + + /// + /// Gets or sets the date and time when the installation was registered + /// + public DateTime InstalledOnUtc { get; set; } + + /// + /// Gets or sets the date and time when the installation was last verified + /// + public DateTime? LastVerifiedOnUtc { get; set; } + + /// + /// Gets or sets additional installation data + /// + public string AdditionalData { get; set; } +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SoftwareSerialNumber.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SoftwareSerialNumber.cs new file mode 100644 index 00000000000..b444063f468 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Domain/SoftwareSerialNumber.cs @@ -0,0 +1,64 @@ +using Nop.Core; + +namespace Nop.Plugin.Misc.SerialNumbers.Domain; + +/// +/// Represents a software serial number +/// +public partial class SoftwareSerialNumber : BaseEntity +{ + /// + /// Gets or sets the serial number code + /// + public string SerialNumber { get; set; } + + /// + /// Gets or sets the order identifier + /// + public int OrderId { get; set; } + + /// + /// Gets or sets the customer identifier + /// + public int CustomerId { get; set; } + + /// + /// Gets or sets the product identifier + /// + public int ProductId { get; set; } + + /// + /// Gets or sets the order item identifier + /// + public int OrderItemId { get; set; } + + /// + /// Gets or sets a value indicating whether the serial number is active + /// + public bool IsActive { get; set; } + + /// + /// Gets or sets the maximum number of installations allowed + /// + public int MaxInstallations { get; set; } + + /// + /// Gets or sets the current number of installations + /// + public int CurrentInstallations { get; set; } + + /// + /// Gets or sets the date and time when the serial number was created + /// + public DateTime CreatedOnUtc { get; set; } + + /// + /// Gets or sets the date and time when the serial number expires + /// + public DateTime? ExpiresOnUtc { get; set; } + + /// + /// Gets or sets additional notes or metadata + /// + public string Notes { get; set; } +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/EventConsumers/OrderPlacedConsumer.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/EventConsumers/OrderPlacedConsumer.cs new file mode 100644 index 00000000000..d12fe13319a --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/EventConsumers/OrderPlacedConsumer.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using Nop.Core.Domain.Orders; +using Nop.Services.Events; +using Nop.Plugin.Misc.SerialNumbers.Services; + +namespace Nop.Plugin.Misc.SerialNumbers.EventConsumers +{ + /// + /// Handles serial number generation when an order is placed + /// + public class OrderPlacedConsumer : IConsumer + { + private readonly ISerialNumberGenerationService _serialNumberGenerationService; + + public OrderPlacedConsumer(ISerialNumberGenerationService serialNumberGenerationService) + { + _serialNumberGenerationService = serialNumberGenerationService; + } + + public async Task HandleEventAsync(OrderPlacedEvent eventMessage) + { + var order = eventMessage.Order; + // foreach (var item in order.Items) + // { + // // Generera serienummer för varje orderrad (produkt) + // await _serialNumberGenerationService.GenerateSerialNumberAsync( + // item.ProductId, + // order.Id, + // item.Id, + // order.CustomerId); + // } + } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Infrastructure/DependencyRegistrar.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Infrastructure/DependencyRegistrar.cs new file mode 100644 index 00000000000..7bfc20d53f0 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Infrastructure/DependencyRegistrar.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Nop.Core.Infrastructure; +using Nop.Plugin.Misc.SerialNumbers.Services; + +namespace Nop.Plugin.Misc.SerialNumbers.Infrastructure; + +/// +/// Dependency registrar for the Serial Numbers plugin +/// +public class DependencyRegistrar : INopStartup +{ + /// + /// Gets order of this dependency registrar implementation + /// + public int Order => 1; + + /// + /// Configure services + /// + /// Collection of service descriptors + /// Configuration of the application + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + services.AddScoped(); + } + + /// + /// Configure the using of added middleware + /// + /// Builder for configuring an application's request pipeline + public void Configure(IApplicationBuilder application) + { + // Nothing to configure + } +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Infrastructure/SerialNumberEventConsumer.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Infrastructure/SerialNumberEventConsumer.cs new file mode 100644 index 00000000000..10fb2b3808a --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Infrastructure/SerialNumberEventConsumer.cs @@ -0,0 +1,131 @@ +using Nop.Core.Domain.Orders; +using Nop.Core.Events; +using Nop.Plugin.Misc.SerialNumbers.Domain; +using Nop.Plugin.Misc.SerialNumbers.Services; +using Nop.Services.Events; +using Nop.Services.Orders; + +namespace Nop.Plugin.Misc.SerialNumbers.Infrastructure; + +/// +/// Event consumer for serial number generation +/// +public class SerialNumberEventConsumer : IConsumer +{ + #region Fields + + private readonly ISoftwareSerialNumberService _serialNumberService; + private readonly IOrderService _orderService; + private readonly SerialNumberSettings _serialNumberSettings; + + #endregion + + #region Ctor + + public SerialNumberEventConsumer( + ISoftwareSerialNumberService serialNumberService, + IOrderService orderService, + SerialNumberSettings serialNumberSettings) + { + _serialNumberService = serialNumberService; + _orderService = orderService; + _serialNumberSettings = serialNumberSettings; + } + + #endregion + + #region Methods + + /// + /// Handle the order paid event + /// + /// The event message. + /// A task that represents the asynchronous operation + public async Task HandleEventAsync(OrderPaidEvent eventMessage) + { + if (eventMessage?.Order == null) + return; + + var order = eventMessage.Order; + + // Check if serial number generation is enabled + if (!_serialNumberSettings.GenerateSerialNumbersOnPayment) + return; + + // foreach (var orderItem in order.OrderItems) + // { + // // Check if this product should generate serial numbers + // if (await ShouldGenerateSerialNumber(orderItem)) + // { + // // Generate serial numbers based on quantity + // for (int i = 0; i < orderItem.Quantity; i++) + // { + // await GenerateSerialNumberForOrderItemAsync(order, orderItem); + // } + // } + // } + } + + #endregion + + #region Utilities + + /// + /// Determines if a serial number should be generated for the order item + /// + /// The order item + /// A task that represents the asynchronous operation. The task result indicates if serial number should be generated + private async Task ShouldGenerateSerialNumber(OrderItem orderItem) + { + // For now, generate serial numbers for all products + // This can be extended to check product attributes, categories, or other criteria + return await Task.FromResult(_serialNumberSettings.GenerateForAllProducts); + } + + /// + /// Generates a serial number for an order item + /// + /// The order + /// The order item + /// A task that represents the asynchronous operation + private async Task GenerateSerialNumberForOrderItemAsync(Nop.Core.Domain.Orders.Order order, OrderItem orderItem) + { + try + { + // Generate the serial number + var serialNumber = await _serialNumberService.GenerateSerialNumberAsync( + order.CustomerId, + order.Id, + orderItem.Id, + orderItem.ProductId); + + // Create the serial number record + var serialNumberRecord = new SoftwareSerialNumber + { + SerialNumber = serialNumber, + OrderId = order.Id, + CustomerId = order.CustomerId, + ProductId = orderItem.ProductId, + OrderItemId = orderItem.Id, + IsActive = true, + MaxInstallations = _serialNumberSettings.DefaultMaxInstallations, + CurrentInstallations = 0, + CreatedOnUtc = DateTime.UtcNow, + ExpiresOnUtc = _serialNumberSettings.DefaultExpirationDays > 0 + ? DateTime.UtcNow.AddDays(_serialNumberSettings.DefaultExpirationDays) + : null, + Notes = $"Generated for order #{order.Id}, product #{orderItem.ProductId}" + }; + + await _serialNumberService.InsertSoftwareSerialNumberAsync(serialNumberRecord); + } + catch (Exception ex) + { + // Log error but don't throw - we don't want to break the order process + // In a real implementation, you would use ILogger here + System.Diagnostics.Debug.WriteLine($"Failed to generate serial number for order {order.Id}, item {orderItem.Id}: {ex.Message}"); + } + } + + #endregion +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Mapping/SerialNumberMap.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Mapping/SerialNumberMap.cs new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Mapping/SerialNumberMap.cs @@ -0,0 +1 @@ + diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Mapping/SerialNumberMaskMap.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Mapping/SerialNumberMaskMap.cs new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Mapping/SerialNumberMaskMap.cs @@ -0,0 +1 @@ + diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Mapping/SerialNumberValidationLogMap.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Mapping/SerialNumberValidationLogMap.cs new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Mapping/SerialNumberValidationLogMap.cs @@ -0,0 +1 @@ + diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/Admin/SerialNumberMaskModel.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/Admin/SerialNumberMaskModel.cs new file mode 100644 index 00000000000..2e93455712b --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/Admin/SerialNumberMaskModel.cs @@ -0,0 +1,24 @@ +using System; +using Nop.Web.Framework.Models; +using Nop.Web.Framework.Mvc.ModelBinding; + +namespace Nop.Plugin.Misc.SerialNumbers.Models.Admin +{ + public record SerialNumberMaskModel : BaseNopEntityModel + { + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Mask")] + public string Mask { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Counter")] + public int Counter { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.ProductId")] + public int? ProductId { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.IsGlobal")] + public bool IsGlobal { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.CreatedOnUtc")] + public DateTime CreatedOnUtc { get; set; } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/CustomerSerialNumberModels.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/CustomerSerialNumberModels.cs new file mode 100644 index 00000000000..a0a151853e2 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/CustomerSerialNumberModels.cs @@ -0,0 +1,205 @@ +namespace Nop.Plugin.Misc.SerialNumbers.Models.Customer; + +/// +/// Model for customer serial number list +/// +public class CustomerSerialNumbersModel +{ + /// + /// Gets or sets the list of serial numbers + /// + public IList SerialNumbers { get; set; } = new List(); + + /// + /// Gets or sets the page index + /// + public int PageIndex { get; set; } + + /// + /// Gets or sets the page size + /// + public int PageSize { get; set; } + + /// + /// Gets or sets the total count + /// + public int TotalCount { get; set; } + + /// + /// Gets or sets a value indicating whether there are more pages + /// + public bool HasNextPage => (PageIndex + 1) * PageSize < TotalCount; + + /// + /// Gets or sets a value indicating whether there are previous pages + /// + public bool HasPreviousPage => PageIndex > 0; +} + +/// +/// Model for a serial number +/// +public class SerialNumberModel +{ + /// + /// Gets or sets the serial number ID + /// + public int Id { get; set; } + + /// + /// Gets or sets the serial number code + /// + public string SerialNumber { get; set; } + + /// + /// Gets or sets the product name + /// + public string ProductName { get; set; } + + /// + /// Gets or sets the product ID + /// + public int ProductId { get; set; } + + /// + /// Gets or sets the order ID + /// + public int OrderId { get; set; } + + /// + /// Gets or sets the order number + /// + public string OrderNumber { get; set; } + + /// + /// Gets or sets a value indicating whether the serial number is active + /// + public bool IsActive { get; set; } + + /// + /// Gets or sets the maximum installations + /// + public int MaxInstallations { get; set; } + + /// + /// Gets or sets the current installations + /// + public int CurrentInstallations { get; set; } + + /// + /// Gets or sets the remaining installations + /// + public int RemainingInstallations => Math.Max(0, MaxInstallations - CurrentInstallations); + + /// + /// Gets or sets the creation date + /// + public DateTime CreatedOn { get; set; } + + /// + /// Gets or sets the expiration date + /// + public DateTime? ExpiresOn { get; set; } + + /// + /// Gets or sets a value indicating whether the serial number is expired + /// + public bool IsExpired => ExpiresOn.HasValue && ExpiresOn.Value < DateTime.UtcNow; + + /// + /// Gets or sets the status text + /// + public string Status => IsExpired ? "Expired" : (IsActive ? "Active" : "Inactive"); + + /// + /// Gets or sets the notes + /// + public string Notes { get; set; } +} + +/// +/// Model for installation details +/// +public class InstallationDetailsModel +{ + /// + /// Gets or sets the serial number + /// + public SerialNumberModel SerialNumber { get; set; } + + /// + /// Gets or sets the list of installations + /// + public IList Installations { get; set; } = new List(); +} + +/// +/// Model for an installation +/// +public class InstallationModel +{ + /// + /// Gets or sets the installation ID + /// + public int Id { get; set; } + + /// + /// Gets or sets the machine ID + /// + public string MachineId { get; set; } + + /// + /// Gets or sets the computer name + /// + public string ComputerName { get; set; } + + /// + /// Gets or sets the operating system + /// + public string OperatingSystem { get; set; } + + /// + /// Gets or sets the processor information + /// + public string ProcessorInfo { get; set; } + + /// + /// Gets or sets the total memory formatted + /// + public string TotalMemoryFormatted { get; set; } + + /// + /// Gets or sets the software version + /// + public string SoftwareVersion { get; set; } + + /// + /// Gets or sets the installation path + /// + public string InstallationPath { get; set; } + + /// + /// Gets or sets the IP address + /// + public string IPAddress { get; set; } + + /// + /// Gets or sets a value indicating whether the installation is active + /// + public bool IsActive { get; set; } + + /// + /// Gets or sets the installation date + /// + public DateTime InstalledOn { get; set; } + + /// + /// Gets or sets the last verified date + /// + public DateTime? LastVerified { get; set; } + + /// + /// Gets or sets the status text + /// + public string Status => IsActive ? "Active" : "Inactive"; +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/SerialNumberApiModels.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/SerialNumberApiModels.cs new file mode 100644 index 00000000000..631350faf9b --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/SerialNumberApiModels.cs @@ -0,0 +1,125 @@ +namespace Nop.Plugin.Misc.SerialNumbers.Models.Api; + +/// +/// Model for serial number validation request +/// +public class SerialNumberValidationRequest +{ + /// + /// Gets or sets the serial number to validate + /// + public string SerialNumber { get; set; } + + /// + /// Gets or sets the machine identifier + /// + public string MachineId { get; set; } + + /// + /// Gets or sets the computer name + /// + public string ComputerName { get; set; } + + /// + /// Gets or sets the operating system information + /// + public string OperatingSystem { get; set; } + + /// + /// Gets or sets the processor information + /// + public string ProcessorInfo { get; set; } + + /// + /// Gets or sets the total physical memory + /// + public long? TotalMemory { get; set; } + + /// + /// Gets or sets the software version + /// + public string SoftwareVersion { get; set; } + + /// + /// Gets or sets the installation path + /// + public string InstallationPath { get; set; } + + /// + /// Gets or sets additional data + /// + public string AdditionalData { get; set; } + + /// + /// Gets or sets the API key (if required) + /// + public string ApiKey { get; set; } +} + +/// +/// Model for serial number validation response +/// +public class SerialNumberValidationResponse +{ + /// + /// Gets or sets a value indicating whether the validation was successful + /// + public bool Success { get; set; } + + /// + /// Gets or sets a value indicating whether installation is allowed + /// + public bool AllowInstallation { get; set; } + + /// + /// Gets or sets the error message if validation fails + /// + public string Message { get; set; } + + /// + /// Gets or sets the remaining installation count + /// + public int RemainingInstallations { get; set; } + + /// + /// Gets or sets the maximum installations allowed + /// + public int MaxInstallations { get; set; } + + /// + /// Gets or sets the serial number expiration date + /// + public DateTime? ExpirationDate { get; set; } + + /// + /// Gets or sets additional validation data + /// + public object Data { get; set; } +} + +/// +/// Model for installation registration request +/// +public class InstallationRegistrationRequest : SerialNumberValidationRequest +{ + /// + /// Gets or sets a value indicating whether to force registration even if machine already exists + /// + public bool ForceRegistration { get; set; } +} + +/// +/// Model for installation registration response +/// +public class InstallationRegistrationResponse : SerialNumberValidationResponse +{ + /// + /// Gets or sets the installation ID + /// + public int InstallationId { get; set; } + + /// + /// Gets or sets the registration date + /// + public DateTime RegistrationDate { get; set; } +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/SerialNumberSettingsModel.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/SerialNumberSettingsModel.cs new file mode 100644 index 00000000000..865fd01ba99 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/SerialNumberSettingsModel.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using Nop.Web.Framework.Models; +using Nop.Web.Framework.Mvc.ModelBinding; + +namespace Nop.Plugin.Misc.SerialNumbers.Models; + +public record SerialNumberSettingsModel : BaseNopModel +{ + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Configuration.GenerateOnPayment")] + public bool GenerateSerialNumbersOnPayment { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Configuration.GenerateForAllProducts")] + public bool GenerateForAllProducts { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Configuration.DefaultMaxInstallations")] + [Range(1, 100, ErrorMessage = "Plugins.Misc.SerialNumbers.Configuration.DefaultMaxInstallations.Range")] + [Required(ErrorMessage = "Plugins.Misc.SerialNumbers.Configuration.DefaultMaxInstallations.Required")] + public int DefaultMaxInstallations { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Configuration.DefaultExpirationDays")] + [Range(0, 3650, ErrorMessage = "Plugins.Misc.SerialNumbers.Configuration.DefaultExpirationDays.Range")] + [Required(ErrorMessage = "Plugins.Misc.SerialNumbers.Configuration.DefaultExpirationDays.Required")] + public int DefaultExpirationDays { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Configuration.AllowCustomerView")] + public bool AllowCustomersToViewSerialNumbers { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Configuration.AllowInstallationView")] + public bool AllowCustomersToViewInstallations { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Configuration.ApiKey")] + [StringLength(100, ErrorMessage = "Plugins.Misc.SerialNumbers.Configuration.ApiKey.MaxLength")] + [DataType(DataType.Password)] + public string ApiKey { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Configuration.RequireApiKey")] + public bool RequireApiKey { get; set; } + + [NopResourceDisplayName("Plugins.Misc.SerialNumbers.Configuration.LogRequests")] + public bool LogValidationRequests { get; set; } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/SerialNumberValidationLogModel.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/SerialNumberValidationLogModel.cs new file mode 100644 index 00000000000..40559ce12bb --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Models/SerialNumberValidationLogModel.cs @@ -0,0 +1,21 @@ +using System; + +namespace Nop.Plugin.Misc.SerialNumbers.Models +{ + /// + /// Model for logging serial number validation attempts + /// + public class SerialNumberValidationLogModel + { + public string SerialNumber { get; set; } + public string Username { get; set; } + public string Company { get; set; } + public string Email { get; set; } + public string Version { get; set; } + public string LanguageId { get; set; } + public string AdditionalInfo { get; set; } + public bool IsValid { get; set; } + public string ErrorMessage { get; set; } + public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow; + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Nop.Plugin.Misc.SerialNumbers.csproj b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Nop.Plugin.Misc.SerialNumbers.csproj new file mode 100644 index 00000000000..ed7eb2074dd --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Nop.Plugin.Misc.SerialNumbers.csproj @@ -0,0 +1,40 @@ + + + + net9.0 + Copyright © Need2Code AB 2025 + Need2Code AB + Need2Code AB + + + + Git + ..\..\Presentation\Nop.Web\Plugins\Misc.SerialNumbers\ + $(OutputPath) + latest + Nop.Plugin.Misc.SerialNumbers + true + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + + \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Plugin.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Plugin.cs new file mode 100644 index 00000000000..d230f9f3aea --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Plugin.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Nop.Core.Infrastructure; +using Nop.Web.Framework.Infrastructure.Extensions; +using Nop.Web.Framework.Menu; + +namespace Nop.Plugin.Misc.SerialNumbers +{ + public class Plugin : INopStartup + { + public void ConfigureServices(IServiceCollection services, Microsoft.Extensions.Configuration.IConfiguration configuration) + { + } + + public void Configure(IApplicationBuilder application) + { + // Ingen ytterligare konfiguration krävs + } + + public int Order => 0; + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/README.md b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/README.md new file mode 100644 index 00000000000..432b6db59a0 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/README.md @@ -0,0 +1,39 @@ +# Nop.Plugin.Misc.SerialNumbers + +## Overview +This plugin provides advanced serial number management for NopCommerce 4.60+ stores. It enables flexible generation, assignment, validation, and administration of software serial numbers for digital products, with full support for customer and order integration. + +## Features +- **Serial Number Generation**: Define custom serial number masks (global or per product) with automatic counters for unique, sequential serials. +- **Assignment**: Serial numbers are assigned to customers and orders at purchase, supporting both product-level and global rules. +- **Validation & Logging**: API endpoints and admin tools for validating serial numbers, logging installation attempts, and tracking usage (including max/current installations). +- **Admin Management**: Full CRUD UI for serial number masks and issued serials. Admins can create, edit, or delete serials, adjust counters, and review validation logs. +- **Customer View**: Customers can view their serial numbers and installation status in their account area. +- **Expiration & Notes**: Support for expiration dates and admin notes per serial number. +- **Security**: All operations follow NopCommerce and .NET security best practices. No API key is required for validation—lookup is performed securely in the database. +- **Extensibility**: Built with SOLID principles, dependency injection, and testability in mind. All code follows NopCommerce plugin architecture and .NET 9 best practices. + +## Technical Highlights +- **No EF Core in Plugin**: All data access uses Nop.Data, IRepository, and IMigration. No DbContext or EF Core mapping in the plugin. +- **Migrations**: Database tables are created via FluentMigrator and Nop's migration system. +- **Async/Await**: All operations are asynchronous for scalability. +- **Multi-DB Support**: Compatible with MS SQL, MySQL, PostgreSQL, and Docker environments. +- **Modern UI**: Razor views with external CSS for all admin and customer interfaces. + +## Use Cases +- Issue unique serial numbers for software or digital products. +- Track and limit installations per serial number. +- Allow customers to view and manage their serials. +- Enable admin to audit, correct, or re-issue serials as needed. + +## Roadmap +- Bulk import/export of serial numbers +- Advanced reporting and analytics +- Integration with external licensing/activation systems + +## Requirements +- NopCommerce 4.60+ +- .NET 9 + +## Authors & License +Developed by Need2Code AB. MIT License. \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/SerialNumberPlugin.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/SerialNumberPlugin.cs new file mode 100644 index 00000000000..7de1bfd3e6e --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/SerialNumberPlugin.cs @@ -0,0 +1,167 @@ +using Nop.Core; +using Nop.Plugin.Misc.SerialNumbers.Data; +using Nop.Services.Configuration; +using Nop.Services.Localization; +using Nop.Services.Plugins; + +namespace Nop.Plugin.Misc.SerialNumbers; + +/// +/// Serial Number Manager plugin +/// +public class SerialNumberPlugin : BasePlugin +{ + #region Fields + + private readonly ILocalizationService _localizationService; + private readonly ISettingService _settingService; + private readonly IWebHelper _webHelper; + + #endregion + + #region Ctor + + public SerialNumberPlugin( + ILocalizationService localizationService, + ISettingService settingService, + IWebHelper webHelper) + { + _localizationService = localizationService; + _settingService = settingService; + _webHelper = webHelper; + } + + #endregion + + #region Methods + /// + /// Gets a configuration page URL + /// + public override string GetConfigurationPageUrl() + { + return $"{_webHelper.GetStoreLocation()}Admin/SerialNumbers/Configure"; + } + + /// + /// Gets a configuration page URL + /// + + /// + /// Install the plugin + /// + /// A task that represents the asynchronous operation + public override async Task InstallAsync() + { + // Settings + var settings = new SerialNumberSettings + { + GenerateSerialNumbersOnPayment = true, + GenerateForAllProducts = true, + DefaultMaxInstallations = 1, + DefaultExpirationDays = 0, + AllowCustomersToViewSerialNumbers = true, + AllowCustomersToViewInstallations = true, + RequireApiKey = false, + LogValidationRequests = true + }; + await _settingService.SaveSettingAsync(settings); + + // Localization resources + await _localizationService.AddOrUpdateLocaleResourceAsync(new Dictionary + { + ["Plugins.Misc.SerialNumbers.Fields.SerialNumber"] = "Serial Number", + ["Plugins.Misc.SerialNumbers.Fields.SerialNumber.Hint"] = "The unique serial number for this product.", + ["Plugins.Misc.SerialNumbers.Fields.Product"] = "Product", + ["Plugins.Misc.SerialNumbers.Fields.Product.Hint"] = "The product associated with this serial number.", + ["Plugins.Misc.SerialNumbers.Fields.Customer"] = "Customer", + ["Plugins.Misc.SerialNumbers.Fields.Customer.Hint"] = "The customer who owns this serial number.", + ["Plugins.Misc.SerialNumbers.Fields.Order"] = "Order", + ["Plugins.Misc.SerialNumbers.Fields.Order.Hint"] = "The order in which this serial number was generated.", + ["Plugins.Misc.SerialNumbers.Fields.IsActive"] = "Active", + ["Plugins.Misc.SerialNumbers.Fields.IsActive.Hint"] = "Indicates whether this serial number is active.", + ["Plugins.Misc.SerialNumbers.Fields.MaxInstallations"] = "Max Installations", + ["Plugins.Misc.SerialNumbers.Fields.MaxInstallations.Hint"] = "Maximum number of installations allowed for this serial number.", + ["Plugins.Misc.SerialNumbers.Fields.CurrentInstallations"] = "Current Installations", + ["Plugins.Misc.SerialNumbers.Fields.CurrentInstallations.Hint"] = "Current number of active installations.", + ["Plugins.Misc.SerialNumbers.Fields.CreatedOn"] = "Created On", + ["Plugins.Misc.SerialNumbers.Fields.CreatedOn.Hint"] = "Date when the serial number was created.", + ["Plugins.Misc.SerialNumbers.Fields.ExpiresOn"] = "Expires On", + ["Plugins.Misc.SerialNumbers.Fields.ExpiresOn.Hint"] = "Date when the serial number expires (if applicable).", + ["Plugins.Misc.SerialNumbers.Fields.Notes"] = "Notes", + ["Plugins.Misc.SerialNumbers.Fields.Notes.Hint"] = "Additional notes or comments.", + + // Customer-facing strings + ["Plugins.Misc.SerialNumbers.Customer.Title"] = "My Serial Numbers", + ["Plugins.Misc.SerialNumbers.Customer.NoSerialNumbers"] = "You don't have any serial numbers yet.", + ["Plugins.Misc.SerialNumbers.Customer.SerialNumber"] = "Serial Number", + ["Plugins.Misc.SerialNumbers.Customer.Product"] = "Product", + ["Plugins.Misc.SerialNumbers.Customer.Order"] = "Order", + ["Plugins.Misc.SerialNumbers.Customer.Status"] = "Status", + ["Plugins.Misc.SerialNumbers.Customer.Installations"] = "Installations", + ["Plugins.Misc.SerialNumbers.Customer.CreatedOn"] = "Created", + ["Plugins.Misc.SerialNumbers.Customer.ExpiresOn"] = "Expires", + ["Plugins.Misc.SerialNumbers.Customer.ViewInstallations"] = "View Installations", + ["Plugins.Misc.SerialNumbers.Customer.Active"] = "Active", + ["Plugins.Misc.SerialNumbers.Customer.Inactive"] = "Inactive", + ["Plugins.Misc.SerialNumbers.Customer.Expired"] = "Expired", + + // Installation strings + ["Plugins.Misc.SerialNumbers.Installations.Title"] = "Installation History", + ["Plugins.Misc.SerialNumbers.Installations.MachineId"] = "Machine ID", + ["Plugins.Misc.SerialNumbers.Installations.ComputerName"] = "Computer Name", + ["Plugins.Misc.SerialNumbers.Installations.OperatingSystem"] = "Operating System", + ["Plugins.Misc.SerialNumbers.Installations.InstalledOn"] = "Installed On", + ["Plugins.Misc.SerialNumbers.Installations.LastVerified"] = "Last Verified", + ["Plugins.Misc.SerialNumbers.Installations.Status"] = "Status", + ["Plugins.Misc.SerialNumbers.Installations.SoftwareVersion"] = "Software Version", + + // Configuration strings + ["Plugins.Misc.SerialNumbers.Configuration.GenerateOnPayment"] = "Generate on Payment", + ["Plugins.Misc.SerialNumbers.Configuration.GenerateOnPayment.Hint"] = "Generate serial numbers automatically when orders are paid.", + ["Plugins.Misc.SerialNumbers.Configuration.GenerateForAllProducts"] = "Generate for All Products", + ["Plugins.Misc.SerialNumbers.Configuration.GenerateForAllProducts.Hint"] = "Generate serial numbers for all products, not just specific ones.", + ["Plugins.Misc.SerialNumbers.Configuration.DefaultMaxInstallations"] = "Default Max Installations", + ["Plugins.Misc.SerialNumbers.Configuration.DefaultMaxInstallations.Hint"] = "Default maximum number of installations per serial number.", + ["Plugins.Misc.SerialNumbers.Configuration.DefaultExpirationDays"] = "Default Expiration Days", + ["Plugins.Misc.SerialNumbers.Configuration.DefaultExpirationDays.Hint"] = "Default number of days until serial numbers expire (0 = no expiration).", + ["Plugins.Misc.SerialNumbers.Configuration.AllowCustomerView"] = "Allow Customer View", + ["Plugins.Misc.SerialNumbers.Configuration.AllowCustomerView.Hint"] = "Allow customers to view their serial numbers in their account.", + ["Plugins.Misc.SerialNumbers.Configuration.AllowInstallationView"] = "Allow Installation View", + ["Plugins.Misc.SerialNumbers.Configuration.AllowInstallationView.Hint"] = "Allow customers to view their installation details.", + ["Plugins.Misc.SerialNumbers.Configuration.ApiKey"] = "API Key", + ["Plugins.Misc.SerialNumbers.Configuration.ApiKey.Hint"] = "API key for server-side validation (optional).", + ["Plugins.Misc.SerialNumbers.Configuration.RequireApiKey"] = "Require API Key", + ["Plugins.Misc.SerialNumbers.Configuration.RequireApiKey.Hint"] = "Require API key for validation requests.", + ["Plugins.Misc.SerialNumbers.Configuration.LogRequests"] = "Log Validation Requests", + ["Plugins.Misc.SerialNumbers.Configuration.LogRequests.Hint"] = "Log all validation requests for debugging and analytics.", + + // Validation messages + ["Plugins.Misc.SerialNumbers.Validation.InvalidSerialNumber"] = "Invalid serial number.", + ["Plugins.Misc.SerialNumbers.Validation.SerialNumberInactive"] = "Serial number is not active.", + ["Plugins.Misc.SerialNumbers.Validation.SerialNumberExpired"] = "Serial number has expired.", + ["Plugins.Misc.SerialNumbers.Validation.MaxInstallationsReached"] = "Maximum number of installations reached.", + ["Plugins.Misc.SerialNumbers.Validation.MachineIdRequired"] = "Machine ID is required.", + ["Plugins.Misc.SerialNumbers.Validation.Success"] = "Validation successful.", + ["Plugins.Misc.SerialNumbers.Validation.UnauthorizedApiKey"] = "Invalid or missing API key." + }); + + await base.InstallAsync(); + } + + /// + /// Uninstall the plugin + /// + /// A task that represents the asynchronous operation + public override async Task UninstallAsync() + { + // Settings + await _settingService.DeleteSettingAsync(); + + // Localization resources + await _localizationService.DeleteLocaleResourcesAsync("Plugins.Misc.SerialNumbers"); + + await base.UninstallAsync(); + } + + #endregion +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/SerialNumberSettings.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/SerialNumberSettings.cs new file mode 100644 index 00000000000..913c7641f6b --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/SerialNumberSettings.cs @@ -0,0 +1,54 @@ +using Nop.Core.Configuration; + +namespace Nop.Plugin.Misc.SerialNumbers; + +/// +/// Represents settings for the Serial Numbers plugin +/// +public class SerialNumberSettings : ISettings +{ + /// + /// Gets or sets a value indicating whether to generate serial numbers on payment completion + /// + public bool GenerateSerialNumbersOnPayment { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to generate serial numbers for all products + /// + public bool GenerateForAllProducts { get; set; } = true; + + /// + /// Gets or sets the default maximum number of installations per serial number + /// + public int DefaultMaxInstallations { get; set; } = 1; + + /// + /// Gets or sets the default expiration days for serial numbers (0 = no expiration) + /// + public int DefaultExpirationDays { get; set; } = 0; + + /// + /// Gets or sets a value indicating whether customers can view their serial numbers + /// + public bool AllowCustomersToViewSerialNumbers { get; set; } = true; + + /// + /// Gets or sets a value indicating whether customers can view installation details + /// + public bool AllowCustomersToViewInstallations { get; set; } = true; + + /// + /// Gets or sets the API key for server-side validation (optional) + /// + public string ApiKey { get; set; } + + /// + /// Gets or sets a value indicating whether to require API key for validation requests + /// + public bool RequireApiKey { get; set; } = false; + + /// + /// Gets or sets a value indicating whether to log all validation requests + /// + public bool LogValidationRequests { get; set; } = true; +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/SerialNumbersAdminMenu.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/SerialNumbersAdminMenu.cs new file mode 100644 index 00000000000..b1511e25bf4 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/SerialNumbersAdminMenu.cs @@ -0,0 +1,17 @@ +using Nop.Services.Plugins; +using Nop.Web.Framework.Events; + +namespace Nop.Plugin.Misc.SerialNumbers +{ + public class SerialNumbersAdminMenuHandler : BaseAdminMenuCreatedEventConsumer + { + public SerialNumbersAdminMenuHandler(IPluginManager pluginManager) + : base(pluginManager) + { + } + + protected override string PluginSystemName => "Misc.SerialNumbers"; + + protected override string BeforeMenuSystemName => "Local plugins"; + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISerialNumberGenerationService.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISerialNumberGenerationService.cs new file mode 100644 index 00000000000..e7c0af0b737 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISerialNumberGenerationService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; + +namespace Nop.Plugin.Misc.SerialNumbers.Services +{ + /// + /// Service for generating serial numbers based on mask and counter + /// + public interface ISerialNumberGenerationService + { + /// + /// Generates a unique serial number for a product/order/customer + /// + /// Product ID + /// Order ID + /// Order item ID + /// Customer ID + /// The generated serial number + Task GenerateSerialNumberAsync(int productId, int orderId, int orderItemId, int customerId); + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISerialNumberValidationLogService.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISerialNumberValidationLogService.cs new file mode 100644 index 00000000000..a8217d18d4a --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISerialNumberValidationLogService.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace Nop.Plugin.Misc.SerialNumbers.Services +{ + /// + /// Service for logging serial number validation attempts (Advanced Installer) + /// + public interface ISerialNumberValidationLogService + { + Task LogValidationAttemptAsync(Models.SerialNumberValidationLogModel logModel); + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISoftwareInstallationService.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISoftwareInstallationService.cs new file mode 100644 index 00000000000..c7a927c73ce --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISoftwareInstallationService.cs @@ -0,0 +1,97 @@ +using Nop.Plugin.Misc.SerialNumbers.Domain; + +namespace Nop.Plugin.Misc.SerialNumbers.Services; + +/// +/// Interface for software installation service +/// +public interface ISoftwareInstallationService +{ + /// + /// Gets a software installation by identifier + /// + /// The installation identifier + /// A task that represents the asynchronous operation. The task result contains the installation + Task GetInstallationByIdAsync(int id); + + /// + /// Gets all installations for a serial number + /// + /// The serial number identifier + /// Whether to return only active installations + /// A task that represents the asynchronous operation. The task result contains the list of installations + Task> GetInstallationsBySerialNumberIdAsync(int serialNumberId, bool activeOnly = false); + + /// + /// Gets installation by serial number and machine ID + /// + /// The serial number identifier + /// The machine identifier + /// A task that represents the asynchronous operation. The task result contains the installation + Task GetInstallationByMachineIdAsync(int serialNumberId, string machineId); + + /// + /// Inserts a software installation + /// + /// The installation + /// A task that represents the asynchronous operation + Task InsertInstallationAsync(SoftwareInstallation installation); + + /// + /// Updates a software installation + /// + /// The installation + /// A task that represents the asynchronous operation + Task UpdateInstallationAsync(SoftwareInstallation installation); + + /// + /// Deletes a software installation + /// + /// The installation + /// A task that represents the asynchronous operation + Task DeleteInstallationAsync(SoftwareInstallation installation); + + /// + /// Registers a new software installation + /// + /// The serial number identifier + /// The machine identifier + /// The computer name + /// The operating system + /// The processor information + /// The total memory + /// The software version + /// The installation path + /// The IP address + /// The user agent + /// Additional data + /// A task that represents the asynchronous operation. The task result contains the installation + Task RegisterInstallationAsync( + int serialNumberId, + string machineId, + string computerName = null, + string operatingSystem = null, + string processorInfo = null, + long? totalMemory = null, + string softwareVersion = null, + string installationPath = null, + string ipAddress = null, + string userAgent = null, + string additionalData = null); + + /// + /// Verifies an installation (updates last verified date) + /// + /// The serial number identifier + /// The machine identifier + /// A task that represents the asynchronous operation. The task result indicates if verification was successful + Task VerifyInstallationAsync(int serialNumberId, string machineId); + + /// + /// Deactivates an installation + /// + /// The serial number identifier + /// The machine identifier + /// A task that represents the asynchronous operation. The task result indicates if deactivation was successful + Task DeactivateInstallationAsync(int serialNumberId, string machineId); +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISoftwareSerialNumberService.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISoftwareSerialNumberService.cs new file mode 100644 index 00000000000..868df8b25e8 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/ISoftwareSerialNumberService.cs @@ -0,0 +1,105 @@ +using Nop.Core; +using Nop.Plugin.Misc.SerialNumbers.Domain; + +namespace Nop.Plugin.Misc.SerialNumbers.Services; + +/// +/// Interface for software serial number service +/// +public interface ISoftwareSerialNumberService +{ + /// + /// Gets a software serial number by identifier + /// + /// The serial number identifier + /// A task that represents the asynchronous operation. The task result contains the serial number + Task GetSoftwareSerialNumberByIdAsync(int id); + + /// + /// Gets a software serial number by its code + /// + /// The serial number code + /// A task that represents the asynchronous operation. The task result contains the serial number + Task GetSoftwareSerialNumberByCodeAsync(string serialNumber); + + /// + /// Gets all software serial numbers for a customer + /// + /// The customer identifier + /// Page index + /// Page size + /// A task that represents the asynchronous operation. The task result contains the paged list of serial numbers + Task> GetCustomerSerialNumbersAsync(int customerId, int pageIndex = 0, int pageSize = int.MaxValue); + + /// + /// Gets all software serial numbers for an order + /// + /// The order identifier + /// A task that represents the asynchronous operation. The task result contains the list of serial numbers + Task> GetOrderSerialNumbersAsync(int orderId); + + /// + /// Inserts a software serial number + /// + /// The serial number + /// A task that represents the asynchronous operation + Task InsertSoftwareSerialNumberAsync(SoftwareSerialNumber serialNumber); + + /// + /// Updates a software serial number + /// + /// The serial number + /// A task that represents the asynchronous operation + Task UpdateSoftwareSerialNumberAsync(SoftwareSerialNumber serialNumber); + + /// + /// Deletes a software serial number + /// + /// The serial number + /// A task that represents the asynchronous operation + Task DeleteSoftwareSerialNumberAsync(SoftwareSerialNumber serialNumber); + + /// + /// Generates a unique serial number for an order item + /// + /// The customer identifier + /// The order identifier + /// The order item identifier + /// The product identifier + /// A task that represents the asynchronous operation. The task result contains the generated serial number + Task GenerateSerialNumberAsync(int customerId, int orderId, int orderItemId, int productId); + + /// + /// Validates a serial number and machine information for installation + /// + /// The serial number code + /// The machine identifier + /// A task that represents the asynchronous operation. The task result contains validation result + Task ValidateSerialNumberAsync(string serialNumber, string machineId); +} + +/// +/// Represents the result of serial number validation +/// +public class SerialNumberValidationResult +{ + /// + /// Gets or sets a value indicating whether the serial number is valid + /// + public bool IsValid { get; set; } + + /// + /// Gets or sets the error message if validation fails + /// + public string ErrorMessage { get; set; } + + /// + /// Gets or sets a value indicating whether the machine can install the software + /// + public bool CanInstall { get; set; } + + /// + /// Gets or sets the remaining installation count + /// + public int RemainingInstallations { get; set; } +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SerialNumberGenerationService.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SerialNumberGenerationService.cs new file mode 100644 index 00000000000..ecdbca89cd8 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SerialNumberGenerationService.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using Nop.Data; +using Nop.Plugin.Misc.SerialNumbers.Domain; + +namespace Nop.Plugin.Misc.SerialNumbers.Services +{ + /// + public class SerialNumberGenerationService : ISerialNumberGenerationService + { + private readonly IRepository _maskRepository; + private readonly IRepository _serialRepository; + + public SerialNumberGenerationService( + IRepository maskRepository, + IRepository serialRepository) + { + _maskRepository = maskRepository; + _serialRepository = serialRepository; + } + + public async Task GenerateSerialNumberAsync(int productId, int orderId, int orderItemId, int customerId) + { + // Hämta mask för produkt, annars global + var mask = await _maskRepository.Table + .FirstOrDefaultAsync(m => m.ProductId == productId) ?? + await _maskRepository.Table.FirstOrDefaultAsync(m => m.IsGlobal); + if (mask == null) + throw new InvalidOperationException("Ingen serienummermask definierad."); + + // Öka räknare atomärt + mask.Counter++; + mask.UpdatedOnUtc = DateTime.UtcNow; + await _maskRepository.UpdateAsync(mask); + + // Generera serienummer enligt mask + var serial = ApplyMask(mask.Mask, mask.Counter); + + // Spara serienummer + var serialEntity = new SerialNumber + { + OrderId = orderId, + OrderItemId = orderItemId, + CustomerId = customerId, + MaskId = mask.Id, + Serial = serial, + CreatedOnUtc = DateTime.UtcNow + }; + await _serialRepository.InsertAsync(serialEntity); + + return serial; + } + + private string ApplyMask(string mask, int counter) + { + // Exempel: mask = "ABC-####-YYYY" => ersätt #### med counter, YYYY med år + var year = DateTime.UtcNow.Year.ToString(); + var serial = mask.Replace("####", counter.ToString("D4")).Replace("YYYY", year); + return serial; + } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SerialNumberValidationLogService.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SerialNumberValidationLogService.cs new file mode 100644 index 00000000000..0b994e91ef9 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SerialNumberValidationLogService.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using Nop.Plugin.Misc.SerialNumbers.Domain; +using Nop.Data; + +namespace Nop.Plugin.Misc.SerialNumbers.Services +{ + /// + public class SerialNumberValidationLogService : ISerialNumberValidationLogService + { + private readonly IRepository _logRepository; + + public SerialNumberValidationLogService(IRepository logRepository) + { + _logRepository = logRepository; + } + + public async Task LogValidationAttemptAsync(Models.SerialNumberValidationLogModel logModel) + { + var log = new Domain.SerialNumberValidationLog + { + SerialNumber = logModel.SerialNumber, + Username = logModel.Username, + Company = logModel.Company, + Email = logModel.Email, + Version = logModel.Version, + LanguageId = logModel.LanguageId, + AdditionalInfo = logModel.AdditionalInfo, + IsValid = logModel.IsValid, + ErrorMessage = logModel.ErrorMessage, + CreatedOnUtc = logModel.CreatedOnUtc + }; + await _logRepository.InsertAsync(log); + } + } +} diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SoftwareInstallationService.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SoftwareInstallationService.cs new file mode 100644 index 00000000000..56ad8396bf6 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SoftwareInstallationService.cs @@ -0,0 +1,257 @@ +using Nop.Data; +using Nop.Plugin.Misc.SerialNumbers.Domain; + +namespace Nop.Plugin.Misc.SerialNumbers.Services; + +/// +/// Software installation service implementation +/// +public class SoftwareInstallationService : ISoftwareInstallationService +{ + #region Fields + + private readonly IRepository _installationRepository; + private readonly IRepository _serialNumberRepository; + + #endregion + + #region Ctor + + public SoftwareInstallationService( + IRepository installationRepository, + IRepository serialNumberRepository) + { + _installationRepository = installationRepository; + _serialNumberRepository = serialNumberRepository; + } + + #endregion + + #region Methods + + /// + /// Gets a software installation by identifier + /// + /// The installation identifier + /// A task that represents the asynchronous operation. The task result contains the installation + public virtual async Task GetInstallationByIdAsync(int id) + { + return await _installationRepository.GetByIdAsync(id); + } + + /// + /// Gets all installations for a serial number + /// + /// The serial number identifier + /// Whether to return only active installations + /// A task that represents the asynchronous operation. The task result contains the list of installations + public virtual async Task> GetInstallationsBySerialNumberIdAsync(int serialNumberId, bool activeOnly = false) + { + var query = from i in _installationRepository.Table + where i.SerialNumberId == serialNumberId + select i; + + if (activeOnly) + query = query.Where(i => i.IsActive); + + query = query.OrderByDescending(i => i.InstalledOnUtc); + + return await query.ToListAsync(); + } + + /// + /// Gets installation by serial number and machine ID + /// + /// The serial number identifier + /// The machine identifier + /// A task that represents the asynchronous operation. The task result contains the installation + public virtual async Task GetInstallationByMachineIdAsync(int serialNumberId, string machineId) + { + if (string.IsNullOrEmpty(machineId)) + return null; + + var query = from i in _installationRepository.Table + where i.SerialNumberId == serialNumberId && i.MachineId == machineId + orderby i.InstalledOnUtc descending + select i; + + return await query.FirstOrDefaultAsync(); + } + + /// + /// Inserts a software installation + /// + /// The installation + /// A task that represents the asynchronous operation + public virtual async Task InsertInstallationAsync(SoftwareInstallation installation) + { + ArgumentNullException.ThrowIfNull(installation); + await _installationRepository.InsertAsync(installation); + } + + /// + /// Updates a software installation + /// + /// The installation + /// A task that represents the asynchronous operation + public virtual async Task UpdateInstallationAsync(SoftwareInstallation installation) + { + ArgumentNullException.ThrowIfNull(installation); + await _installationRepository.UpdateAsync(installation); + } + + /// + /// Deletes a software installation + /// + /// The installation + /// A task that represents the asynchronous operation + public virtual async Task DeleteInstallationAsync(SoftwareInstallation installation) + { + ArgumentNullException.ThrowIfNull(installation); + await _installationRepository.DeleteAsync(installation); + } + + /// + /// Registers a new software installation + /// + /// The serial number identifier + /// The machine identifier + /// The computer name + /// The operating system + /// The processor information + /// The total memory + /// The software version + /// The installation path + /// The IP address + /// The user agent + /// Additional data + /// A task that represents the asynchronous operation. The task result contains the installation + public virtual async Task RegisterInstallationAsync( + int serialNumberId, + string machineId, + string computerName = null, + string operatingSystem = null, + string processorInfo = null, + long? totalMemory = null, + string softwareVersion = null, + string installationPath = null, + string ipAddress = null, + string userAgent = null, + string additionalData = null) + { + if (string.IsNullOrEmpty(machineId)) + throw new ArgumentException("Machine ID is required", nameof(machineId)); + + // Check if installation already exists for this machine + var existingInstallation = await GetInstallationByMachineIdAsync(serialNumberId, machineId); + + if (existingInstallation != null) + { + // Update existing installation + existingInstallation.ComputerName = computerName ?? existingInstallation.ComputerName; + existingInstallation.OperatingSystem = operatingSystem ?? existingInstallation.OperatingSystem; + existingInstallation.ProcessorInfo = processorInfo ?? existingInstallation.ProcessorInfo; + existingInstallation.TotalMemory = totalMemory ?? existingInstallation.TotalMemory; + existingInstallation.SoftwareVersion = softwareVersion ?? existingInstallation.SoftwareVersion; + existingInstallation.InstallationPath = installationPath ?? existingInstallation.InstallationPath; + existingInstallation.IPAddress = ipAddress ?? existingInstallation.IPAddress; + existingInstallation.UserAgent = userAgent ?? existingInstallation.UserAgent; + existingInstallation.AdditionalData = additionalData ?? existingInstallation.AdditionalData; + existingInstallation.IsActive = true; + existingInstallation.LastVerifiedOnUtc = DateTime.UtcNow; + + await UpdateInstallationAsync(existingInstallation); + return existingInstallation; + } + + // Create new installation + var installation = new SoftwareInstallation + { + SerialNumberId = serialNumberId, + MachineId = machineId, + ComputerName = computerName, + OperatingSystem = operatingSystem, + ProcessorInfo = processorInfo, + TotalMemory = totalMemory, + SoftwareVersion = softwareVersion, + InstallationPath = installationPath, + IPAddress = ipAddress, + UserAgent = userAgent, + AdditionalData = additionalData, + IsActive = true, + InstalledOnUtc = DateTime.UtcNow, + LastVerifiedOnUtc = DateTime.UtcNow + }; + + await InsertInstallationAsync(installation); + + // Update current installation count in serial number + await UpdateSerialNumberInstallationCountAsync(serialNumberId); + + return installation; + } + + /// + /// Verifies an installation (updates last verified date) + /// + /// The serial number identifier + /// The machine identifier + /// A task that represents the asynchronous operation. The task result indicates if verification was successful + public virtual async Task VerifyInstallationAsync(int serialNumberId, string machineId) + { + var installation = await GetInstallationByMachineIdAsync(serialNumberId, machineId); + if (installation == null || !installation.IsActive) + return false; + + installation.LastVerifiedOnUtc = DateTime.UtcNow; + await UpdateInstallationAsync(installation); + + return true; + } + + /// + /// Deactivates an installation + /// + /// The serial number identifier + /// The machine identifier + /// A task that represents the asynchronous operation. The task result indicates if deactivation was successful + public virtual async Task DeactivateInstallationAsync(int serialNumberId, string machineId) + { + var installation = await GetInstallationByMachineIdAsync(serialNumberId, machineId); + if (installation == null) + return false; + + installation.IsActive = false; + await UpdateInstallationAsync(installation); + + // Update current installation count in serial number + await UpdateSerialNumberInstallationCountAsync(serialNumberId); + + return true; + } + + #endregion + + #region Utilities + + /// + /// Updates the current installation count for a serial number + /// + /// The serial number identifier + /// A task that represents the asynchronous operation + private async Task UpdateSerialNumberInstallationCountAsync(int serialNumberId) + { + var serialNumber = await _serialNumberRepository.GetByIdAsync(serialNumberId); + if (serialNumber == null) + return; + + var activeCount = await _installationRepository.Table + .Where(i => i.SerialNumberId == serialNumberId && i.IsActive) + .CountAsync(); + + serialNumber.CurrentInstallations = activeCount; + await _serialNumberRepository.UpdateAsync(serialNumber); + } + + #endregion +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SoftwareSerialNumberService.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SoftwareSerialNumberService.cs new file mode 100644 index 00000000000..25cf370dd8a --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Services/SoftwareSerialNumberService.cs @@ -0,0 +1,259 @@ +using Nop.Core; +using Nop.Data; +using Nop.Plugin.Misc.SerialNumbers.Domain; +using System.Security.Cryptography; +using System.Text; + +namespace Nop.Plugin.Misc.SerialNumbers.Services; + +/// +/// Software serial number service implementation +/// +public class SoftwareSerialNumberService : ISoftwareSerialNumberService +{ + #region Fields + + private readonly IRepository _serialNumberRepository; + private readonly IRepository _installationRepository; + + #endregion + + #region Ctor + + public SoftwareSerialNumberService( + IRepository serialNumberRepository, + IRepository installationRepository) + { + _serialNumberRepository = serialNumberRepository; + _installationRepository = installationRepository; + } + + #endregion + + #region Methods + + /// + /// Gets a software serial number by identifier + /// + /// The serial number identifier + /// A task that represents the asynchronous operation. The task result contains the serial number + public virtual async Task GetSoftwareSerialNumberByIdAsync(int id) + { + return await _serialNumberRepository.GetByIdAsync(id); + } + + /// + /// Gets a software serial number by its code + /// + /// The serial number code + /// A task that represents the asynchronous operation. The task result contains the serial number + public virtual async Task GetSoftwareSerialNumberByCodeAsync(string serialNumber) + { + if (string.IsNullOrEmpty(serialNumber)) + return null; + + var query = from sn in _serialNumberRepository.Table + where sn.SerialNumber == serialNumber + select sn; + + return await query.FirstOrDefaultAsync(); + } + + /// + /// Gets all software serial numbers for a customer + /// + /// The customer identifier + /// Page index + /// Page size + /// A task that represents the asynchronous operation. The task result contains the paged list of serial numbers + public virtual async Task> GetCustomerSerialNumbersAsync(int customerId, int pageIndex = 0, int pageSize = int.MaxValue) + { + var query = from sn in _serialNumberRepository.Table + where sn.CustomerId == customerId + orderby sn.CreatedOnUtc descending + select sn; + + return await query.ToPagedListAsync(pageIndex, pageSize); + } + + /// + /// Gets all software serial numbers for an order + /// + /// The order identifier + /// A task that represents the asynchronous operation. The task result contains the list of serial numbers + public virtual async Task> GetOrderSerialNumbersAsync(int orderId) + { + var query = from sn in _serialNumberRepository.Table + where sn.OrderId == orderId + orderby sn.CreatedOnUtc + select sn; + + return await query.ToListAsync(); + } + + /// + /// Inserts a software serial number + /// + /// The serial number + /// A task that represents the asynchronous operation + public virtual async Task InsertSoftwareSerialNumberAsync(SoftwareSerialNumber serialNumber) + { + ArgumentNullException.ThrowIfNull(serialNumber); + await _serialNumberRepository.InsertAsync(serialNumber); + } + + /// + /// Updates a software serial number + /// + /// The serial number + /// A task that represents the asynchronous operation + public virtual async Task UpdateSoftwareSerialNumberAsync(SoftwareSerialNumber serialNumber) + { + ArgumentNullException.ThrowIfNull(serialNumber); + await _serialNumberRepository.UpdateAsync(serialNumber); + } + + /// + /// Deletes a software serial number + /// + /// The serial number + /// A task that represents the asynchronous operation + public virtual async Task DeleteSoftwareSerialNumberAsync(SoftwareSerialNumber serialNumber) + { + ArgumentNullException.ThrowIfNull(serialNumber); + await _serialNumberRepository.DeleteAsync(serialNumber); + } + + /// + /// Generates a unique serial number for an order item + /// + /// The customer identifier + /// The order identifier + /// The order item identifier + /// The product identifier + /// A task that represents the asynchronous operation. The task result contains the generated serial number + public virtual async Task GenerateSerialNumberAsync(int customerId, int orderId, int orderItemId, int productId) + { + // Get a unique counter for this order item + var existingCount = await _serialNumberRepository.Table + .Where(sn => sn.OrderItemId == orderItemId) + .CountAsync(); + + var counter = existingCount + 1; + + // Generate serial number using format: {CustomerId}-{OrderId}-{Counter} + var baseSerialNumber = $"{customerId:D6}-{orderId:D8}-{counter:D3}"; + + // Add a hash suffix for additional uniqueness and security + var hash = GenerateHash($"{baseSerialNumber}{DateTime.UtcNow.Ticks}"); + var serialNumber = $"{baseSerialNumber}-{hash}"; + + // Ensure uniqueness + while (await _serialNumberRepository.Table.AnyAsync(sn => sn.SerialNumber == serialNumber)) + { + counter++; + baseSerialNumber = $"{customerId:D6}-{orderId:D8}-{counter:D3}"; + hash = GenerateHash($"{baseSerialNumber}{DateTime.UtcNow.Ticks}"); + serialNumber = $"{baseSerialNumber}-{hash}"; + } + + return serialNumber; + } + + /// + /// Validates a serial number and machine information for installation + /// + /// The serial number code + /// The machine identifier + /// A task that represents the asynchronous operation. The task result contains validation result + public virtual async Task ValidateSerialNumberAsync(string serialNumber, string machineId) + { + var result = new SerialNumberValidationResult + { + IsValid = false, + CanInstall = false, + RemainingInstallations = 0 + }; + + if (string.IsNullOrEmpty(serialNumber)) + { + result.ErrorMessage = "Serial number is required"; + return result; + } + + // Find the serial number record + var snRecord = await GetSoftwareSerialNumberByCodeAsync(serialNumber); + if (snRecord == null) + { + result.ErrorMessage = "Invalid serial number"; + return result; + } + + if (!snRecord.IsActive) + { + result.ErrorMessage = "Serial number is not active"; + return result; + } + + if (snRecord.ExpiresOnUtc.HasValue && snRecord.ExpiresOnUtc.Value < DateTime.UtcNow) + { + result.ErrorMessage = "Serial number has expired"; + return result; + } + + result.IsValid = true; + + // Check current installations + var activeInstallations = await _installationRepository.Table + .Where(i => i.SerialNumberId == snRecord.Id && i.IsActive) + .CountAsync(); + + result.RemainingInstallations = Math.Max(0, snRecord.MaxInstallations - activeInstallations); + + if (!string.IsNullOrEmpty(machineId)) + { + // Check if this machine already has an installation + var existingInstallation = await _installationRepository.Table + .FirstOrDefaultAsync(i => i.SerialNumberId == snRecord.Id && i.MachineId == machineId && i.IsActive); + + if (existingInstallation != null) + { + result.CanInstall = true; // Allow re-installation on same machine + } + else if (result.RemainingInstallations > 0) + { + result.CanInstall = true; + } + else + { + result.ErrorMessage = "Maximum number of installations reached"; + } + } + else + { + result.CanInstall = result.RemainingInstallations > 0; + if (!result.CanInstall) + result.ErrorMessage = "Maximum number of installations reached"; + } + + return result; + } + + #endregion + + #region Utilities + + /// + /// Generates a short hash for serial number uniqueness + /// + /// The input string + /// The generated hash + private static string GenerateHash(string input) + { + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(input)); + return Convert.ToBase64String(hash)[..8].Replace('+', 'X').Replace('/', 'Y').Replace('=', 'Z'); + } + + #endregion +} \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Tests/SerialNumbersAdminMenuTests.cs b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Tests/SerialNumbersAdminMenuTests.cs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/Configure.cshtml b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/Configure.cshtml new file mode 100644 index 00000000000..e1aa2340ee0 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/Configure.cshtml @@ -0,0 +1,177 @@ + +@model Nop.Plugin.Misc.SerialNumbers.Models.SerialNumberSettingsModel +@{ + Layout = "_ConfigurePlugin"; + NopHtml.SetActiveMenuItemSystemName("Misc.SerialNumbers"); +} + +@{ + NopHtml.AppendCssFileParts("~/Plugins/Misc.SerialNumbers/Content/serial-numbers.css"); +} + +
+
+
+
+
+ + @T("Plugins.Misc.SerialNumbers.Configuration") +
+
+ + +
+
@T("Plugins.Misc.SerialNumbers.Configuration.BasicSettings")
+
+
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+ + + +
+
+
+
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+ + + +
+
+
+
+
+ + +
+
@T("Plugins.Misc.SerialNumbers.Configuration.CustomerSettings")
+
+
+
+
+ +
+
+ + + +
+
+
+
+
+
+ +
+
+ + + +
+
+
+
+
+ + +
+
@T("Plugins.Misc.SerialNumbers.Configuration.ApiSettings")
+
+
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+ + + +
+
+
+
+
+
+ +
+
+
+ + +
+ + +
+
+
+
+
+ +
+ +
+
+
+
+ + diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/CustomerSerialNumbers/Installations.cshtml b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/CustomerSerialNumbers/Installations.cshtml new file mode 100644 index 00000000000..db175503a02 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/CustomerSerialNumbers/Installations.cshtml @@ -0,0 +1,294 @@ +@using Nop.Plugin.Misc.SerialNumbers.Models.Customer +@inject Nop.Services.Localization.ILocalizationService T +@model InstallationDetailsModel + +@{ + ViewData["Title"] = T.GetResource("Plugins.Misc.SerialNumbers.Installations.Title"); + Layout = "_ColumnsTwo"; +} + +@await Component.InvokeAsync(typeof(CustomerNavigationViewComponent)) + + + + \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/CustomerSerialNumbers/SerialNumbers.cshtml b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/CustomerSerialNumbers/SerialNumbers.cshtml new file mode 100644 index 00000000000..5a56dcb596c --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/CustomerSerialNumbers/SerialNumbers.cshtml @@ -0,0 +1,108 @@ +@model Nop.Plugin.Misc.SerialNumbers.Models.Customer.CustomerSerialNumbersModel + +@{ + ViewData["Title"] = T("Plugins.Misc.SerialNumbers.Customer.Title"); + Layout = "_ColumnsTwo"; +} + +@await Component.InvokeAsync(typeof(CustomerNavigationViewComponent)) + + + + \ No newline at end of file diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/SerialNumberAdmin/Create.cshtml b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/SerialNumberAdmin/Create.cshtml new file mode 100644 index 00000000000..9f682ddd877 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/SerialNumberAdmin/Create.cshtml @@ -0,0 +1,31 @@ +@model Nop.Plugin.Misc.SerialNumbers.Models.Admin.SerialNumberMaskModel +@using Nop.Web.Framework.Infrastructure; +@{ + ViewBag.Title = "Skapa serienummermask"; + Layout = AdminViewLocations.AdminLayoutViewPath; +} +

Skapa serienummermask

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+ + Avbryt +
diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/SerialNumberAdmin/Edit.cshtml b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/SerialNumberAdmin/Edit.cshtml new file mode 100644 index 00000000000..ba6e091f2a5 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/SerialNumberAdmin/Edit.cshtml @@ -0,0 +1,32 @@ +@model Nop.Plugin.Misc.SerialNumbers.Models.Admin.SerialNumberMaskModel +@using Nop.Web.Framework.Infrastructure; +@{ + ViewBag.Title = "Redigera serienummermask"; + Layout = AdminViewLocations.AdminLayoutViewPath; +} +

Redigera serienummermask

+
+ +
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+ + Avbryt +
diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/SerialNumberAdmin/List.cshtml b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/SerialNumberAdmin/List.cshtml new file mode 100644 index 00000000000..e2d2aee8c17 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/SerialNumberAdmin/List.cshtml @@ -0,0 +1,38 @@ +@model IList +@using Nop.Web.Framework.Infrastructure; +@{ + ViewBag.Title = "Serienummermasker"; + Layout = AdminViewLocations.AdminLayoutViewPath; +} +

Serienummermasker

+

+ Skapa ny mask +

+ + + + + + + + + + + + + @foreach (var item in Model) + { + + + + + + + + + } + +
MaskRäknareProduktIdGlobalSkapad
@item.Mask@item.Counter@item.ProductId@item.IsGlobal@item.CreatedOnUtc.ToString("yyyy-MM-dd HH:mm") + Redigera + Radera +
diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/_ViewImports.cshtml b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/_ViewImports.cshtml new file mode 100644 index 00000000000..2f187b36baf --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/Views/_ViewImports.cshtml @@ -0,0 +1,13 @@ +@inherits Nop.Web.Framework.Mvc.Razor.NopRazorPage +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Nop.Web.Framework + +@inject INopHtmlHelper NopHtml + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Nop.Web.Framework.UI +@using Nop.Web.Framework.Extensions +@using System.Text.Encodings.Web +@using Nop.Services.Events +@using Nop.Web.Framework.Events +@using Nop.Web.Framework.Infrastructure diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/copilot-instructions.md b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/copilot-instructions.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Plugins/Nop.Plugin.Misc.SerialNumbers/plugin.json b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/plugin.json new file mode 100644 index 00000000000..ad281807af0 --- /dev/null +++ b/src/Plugins/Nop.Plugin.Misc.SerialNumbers/plugin.json @@ -0,0 +1,11 @@ +{ + "Group": "Miscellaneous", + "FriendlyName": "Serial Number Manager", + "SystemName": "Misc.SerialNumbers", + "Version": "1.0.0", + "SupportedVersions": [ "4.60", "4.70", "4.80" ], + "Author": "Need2Code AB", + "DisplayOrder": 100, + "FileName": "Nop.Plugin.Misc.SerialNumbers.dll", + "Description": "Generates and manages serial numbers for software products upon payment completion. Supports server-side validation for Advanced Installer and tracks installation statistics." +} \ No newline at end of file diff --git a/src/Tests/Nop.Plugin.Misc.SerialNumbers.Tests/Nop.Plugin.Misc.SerialNumbers.Tests.csproj b/src/Tests/Nop.Plugin.Misc.SerialNumbers.Tests/Nop.Plugin.Misc.SerialNumbers.Tests.csproj new file mode 100644 index 00000000000..3300199e134 --- /dev/null +++ b/src/Tests/Nop.Plugin.Misc.SerialNumbers.Tests/Nop.Plugin.Misc.SerialNumbers.Tests.csproj @@ -0,0 +1,15 @@ + + + net9.0 + false + + + + + + + + + + \ No newline at end of file diff --git a/src/Tests/Nop.Plugin.Misc.SerialNumbers.Tests/SerialNumbersAdminMenuTests.cs b/src/Tests/Nop.Plugin.Misc.SerialNumbers.Tests/SerialNumbersAdminMenuTests.cs new file mode 100644 index 00000000000..a9b75c4eef0 --- /dev/null +++ b/src/Tests/Nop.Plugin.Misc.SerialNumbers.Tests/SerialNumbersAdminMenuTests.cs @@ -0,0 +1,26 @@ +using Xunit; +using Nop.Web.Framework.Menu; +using Nop.Plugin.Misc.SerialNumbers; +using Nop.Web.Framework.Events; +using System.Threading.Tasks; + +namespace Nop.Plugin.Misc.SerialNumbers.Tests +{ + public class SerialNumbersAdminMenuTests + { + [Fact] + public async Task MenuItem_Is_Added_To_AdminMenu() + { + // Arrange + var menu = new AdminMenu(); + var eventMessage = new AdminMenuCreatedEvent(menu); + var handler = new SerialNumbersAdminMenuHandler(); + + // Act + await handler.HandleEventAsync(eventMessage); + + // Assert + Assert.Contains(menu.Items, n => n.SystemName == "SerialNumbers" && n.ControllerName == "SerialNumbers" && n.ActionName == "Configure"); + } + } +}