mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat(regex): add .NET regex validation via PowerShell and integrate into frontend
This commit is contained in:
19
Dockerfile
19
Dockerfile
@@ -1,13 +1,28 @@
|
||||
# Dockerfile
|
||||
FROM python:3.9-slim
|
||||
WORKDIR /app
|
||||
# Install git and gosu for user switching
|
||||
RUN apt-get update && apt-get install -y git gosu && rm -rf /var/lib/apt/lists/*
|
||||
# Install git, gosu, and PowerShell Core
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
gosu \
|
||||
wget \
|
||||
ca-certificates \
|
||||
libicu-dev \
|
||||
&& wget -O /tmp/powershell.tar.gz https://github.com/PowerShell/PowerShell/releases/download/v7.4.0/powershell-7.4.0-linux-x64.tar.gz \
|
||||
&& mkdir -p /opt/microsoft/powershell/7 \
|
||||
&& tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \
|
||||
&& chmod +x /opt/microsoft/powershell/7/pwsh \
|
||||
&& ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \
|
||||
&& rm /tmp/powershell.tar.gz \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Copy pre-built files from dist directory
|
||||
COPY dist/backend/app ./app
|
||||
COPY dist/backend/scripts ./app/scripts
|
||||
COPY dist/static ./app/static
|
||||
COPY dist/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Ensure scripts are executable
|
||||
RUN chmod +x /app/scripts/*.ps1 || true
|
||||
# Copy and setup entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
FROM python:3.9
|
||||
WORKDIR /app
|
||||
# Install PowerShell Core
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
ca-certificates \
|
||||
libicu-dev \
|
||||
&& wget -O /tmp/powershell.tar.gz https://github.com/PowerShell/PowerShell/releases/download/v7.4.0/powershell-7.4.0-linux-x64.tar.gz \
|
||||
&& mkdir -p /opt/microsoft/powershell/7 \
|
||||
&& tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \
|
||||
&& chmod +x /opt/microsoft/powershell/7/pwsh \
|
||||
&& ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \
|
||||
&& rm /tmp/powershell.tar.gz \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
# Ensure scripts are executable
|
||||
RUN chmod +x /app/scripts/*.ps1 || true
|
||||
# Use gunicorn with 10-minute timeout
|
||||
CMD ["python", "-m", "app.main"]
|
||||
@@ -226,6 +226,32 @@ def handle_item(category, name):
|
||||
return jsonify({"error": "An unexpected error occurred"}), 500
|
||||
|
||||
|
||||
@bp.route('/regex/verify', methods=['POST'])
|
||||
def verify_regex():
|
||||
"""Verify a regex pattern using .NET regex engine via PowerShell"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No JSON data provided"}), 400
|
||||
|
||||
pattern = data.get('pattern')
|
||||
if not pattern:
|
||||
return jsonify({"error": "Pattern is required"}), 400
|
||||
|
||||
from .utils import verify_dotnet_regex
|
||||
|
||||
success, message = verify_dotnet_regex(pattern)
|
||||
|
||||
if success:
|
||||
return jsonify({"valid": True, "message": "Pattern is valid"}), 200
|
||||
else:
|
||||
return jsonify({"valid": False, "error": message}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error verifying regex pattern")
|
||||
return jsonify({"valid": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/<string:category>/test', methods=['POST'])
|
||||
def run_tests(category):
|
||||
logger.info(f"Received test request for category: {category}")
|
||||
|
||||
@@ -7,6 +7,8 @@ from typing import Dict, List, Any, Tuple, Union
|
||||
import git
|
||||
import regex
|
||||
import logging
|
||||
import subprocess
|
||||
import json
|
||||
from ..db.queries.arr import update_arr_config_on_rename, update_arr_config_on_delete
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -360,6 +362,68 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]:
|
||||
return False, f"Error checking references: {str(e)}"
|
||||
|
||||
|
||||
def verify_dotnet_regex(pattern: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Verify a regex pattern using .NET regex engine via PowerShell.
|
||||
Returns (success, message) tuple.
|
||||
"""
|
||||
try:
|
||||
# Get the path to the validate.ps1 script
|
||||
# In Docker, the structure is /app/app/data/utils.py and script is at /app/scripts/validate.ps1
|
||||
script_path = os.path.join('/app', 'scripts', 'validate.ps1')
|
||||
if not os.path.exists(script_path):
|
||||
# Fallback for local development
|
||||
script_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'scripts', 'validate.ps1')
|
||||
|
||||
# Run PowerShell script, passing pattern via stdin to avoid shell escaping issues
|
||||
result = subprocess.run(
|
||||
['pwsh', '-File', script_path],
|
||||
input=pattern,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode != 0 and not result.stdout:
|
||||
logger.error(f"PowerShell script failed: {result.stderr}")
|
||||
return False, "Failed to validate pattern"
|
||||
|
||||
# Log the raw output for debugging
|
||||
logger.debug(f"PowerShell output: {result.stdout}")
|
||||
|
||||
# Parse JSON output
|
||||
try:
|
||||
output = json.loads(result.stdout.strip())
|
||||
except json.JSONDecodeError:
|
||||
# Try to find JSON in the output
|
||||
lines = result.stdout.strip().split('\n')
|
||||
for line in reversed(lines):
|
||||
if line.strip():
|
||||
try:
|
||||
output = json.loads(line)
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
else:
|
||||
logger.error(f"No valid JSON found in output: {result.stdout}")
|
||||
return False, "Failed to parse validation result"
|
||||
|
||||
if output.get('valid'):
|
||||
return True, output.get('message', 'Pattern is valid')
|
||||
else:
|
||||
return False, output.get('error', 'Invalid pattern')
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Pattern validation timed out")
|
||||
return False, "Pattern validation timed out"
|
||||
except FileNotFoundError:
|
||||
logger.error("PowerShell (pwsh) not found")
|
||||
return False, "PowerShell is not available"
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating pattern: {e}")
|
||||
return False, f"Validation error: {str(e)}"
|
||||
|
||||
|
||||
def update_references(category: str, old_name: str,
|
||||
new_name: str) -> List[str]:
|
||||
"""
|
||||
|
||||
73
backend/scripts/validate.ps1
Executable file
73
backend/scripts/validate.ps1
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Validate a .NET regex pattern
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Pattern
|
||||
)
|
||||
|
||||
# Set output encoding to UTF-8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Read pattern from stdin if not provided as parameter
|
||||
if (-not $Pattern) {
|
||||
$Pattern = [System.Console]::In.ReadToEnd()
|
||||
}
|
||||
|
||||
# Ensure we have a pattern
|
||||
if ([string]::IsNullOrWhiteSpace($Pattern)) {
|
||||
$result = @{
|
||||
valid = $false
|
||||
error = "No pattern provided"
|
||||
}
|
||||
Write-Output (ConvertTo-Json $result -Compress)
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
# Attempt to create a .NET Regex object with the pattern
|
||||
# Using IgnoreCase option as per requirement
|
||||
$regex = [System.Text.RegularExpressions.Regex]::new($Pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
|
||||
# If we get here, the pattern is valid
|
||||
$result = @{
|
||||
valid = $true
|
||||
message = "Pattern is valid .NET regex"
|
||||
}
|
||||
|
||||
Write-Output (ConvertTo-Json $result -Compress)
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
# Pattern is invalid, extract the meaningful part of the error message
|
||||
$errorMessage = $_.Exception.Message
|
||||
|
||||
# Try to extract just the useful part of .NET regex errors
|
||||
if ($errorMessage -match "Invalid pattern '.*?' at offset (\d+)\. (.+)") {
|
||||
$errorMessage = "At position $($matches[1]): $($matches[2])"
|
||||
}
|
||||
elseif ($errorMessage -match 'parsing ".*?" - (.+)') {
|
||||
$errorMessage = $matches[1]
|
||||
}
|
||||
elseif ($errorMessage -match 'Exception calling .* with .* argument\(s\): "(.+)"') {
|
||||
$innerError = $matches[1]
|
||||
if ($innerError -match "Invalid pattern '.*?' at offset (\d+)\. (.+)") {
|
||||
$errorMessage = "At position $($matches[1]): $($matches[2])"
|
||||
}
|
||||
else {
|
||||
$errorMessage = $innerError
|
||||
}
|
||||
}
|
||||
|
||||
# Remove any trailing quotes or periods followed by quotes
|
||||
$errorMessage = $errorMessage -replace '\."$', '.' -replace '"$', ''
|
||||
|
||||
$result = @{
|
||||
valid = $false
|
||||
error = $errorMessage
|
||||
}
|
||||
|
||||
Write-Output (ConvertTo-Json $result -Compress)
|
||||
exit 0
|
||||
}
|
||||
@@ -301,5 +301,15 @@ export const RegexPatterns = {
|
||||
update: (name, data, newName) =>
|
||||
updateItem('regex_pattern', name, data, newName),
|
||||
delete: name => deleteItem('regex_pattern', name),
|
||||
runTests: createSpecialEndpoint('regex_pattern', 'test')
|
||||
runTests: createSpecialEndpoint('regex_pattern', 'test'),
|
||||
verify: async pattern => {
|
||||
try {
|
||||
const response = await axios.post(`${BASE_URL}/regex/verify`, {
|
||||
pattern
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleError(error, 'verify regex pattern');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,9 @@ import React, {useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import MarkdownEditor from '@ui/MarkdownEditor';
|
||||
import AddButton from '@ui/DataBar/AddButton';
|
||||
import {InfoIcon} from 'lucide-react';
|
||||
import {Regex, Loader} from 'lucide-react';
|
||||
import {RegexPatterns} from '@api/data';
|
||||
import Alert from '@ui/Alert';
|
||||
|
||||
const RegexGeneralTab = ({
|
||||
name,
|
||||
@@ -18,6 +20,7 @@ const RegexGeneralTab = ({
|
||||
patternError
|
||||
}) => {
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [validating, setValidating] = useState(false);
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (newTag.trim() && !tags.includes(newTag.trim())) {
|
||||
@@ -33,6 +36,30 @@ const RegexGeneralTab = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidatePattern = async () => {
|
||||
if (!pattern?.trim()) {
|
||||
Alert.warning('Please enter a pattern to validate');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidating(true);
|
||||
|
||||
try {
|
||||
const result = await RegexPatterns.verify(pattern);
|
||||
|
||||
if (result.valid) {
|
||||
Alert.success('Pattern is valid .NET regex');
|
||||
} else {
|
||||
Alert.error(result.error || 'Invalid pattern');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
Alert.error('Failed to validate pattern');
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{error && (
|
||||
@@ -89,17 +116,28 @@ const RegexGeneralTab = ({
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Pattern
|
||||
</label>
|
||||
<div className='flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400'>
|
||||
<InfoIcon className='h-4 w-4' />
|
||||
<span>Case insensitive PCRE2</span>
|
||||
<div>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Pattern
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Enter your regular expression pattern (case-insensitive .NET)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleValidatePattern}
|
||||
disabled={validating || !pattern?.trim()}
|
||||
className='inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md
|
||||
bg-blue-600 hover:bg-blue-700 disabled:bg-blue-600/50 text-white
|
||||
transition-colors duration-200'>
|
||||
{validating ? (
|
||||
<Loader className='w-4 h-4 mr-2 animate-spin' />
|
||||
) : (
|
||||
<Regex className='w-4 h-4 mr-2' />
|
||||
)}
|
||||
Validate
|
||||
</button>
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Enter your regular expression pattern
|
||||
</p>
|
||||
</div>
|
||||
{patternError && (
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
|
||||
@@ -65,6 +65,19 @@ export const useRegexModal = (initialPattern, onSave) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate pattern with .NET regex engine
|
||||
try {
|
||||
const validationResult = await RegexPatterns.verify(patternValue);
|
||||
if (!validationResult.valid) {
|
||||
Alert.error(`Invalid regex pattern: ${validationResult.error || 'Pattern validation failed'}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Pattern validation error:', error);
|
||||
Alert.error('Failed to validate pattern. Please check the pattern and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
name,
|
||||
|
||||
Reference in New Issue
Block a user