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
|
# Dockerfile
|
||||||
FROM python:3.9-slim
|
FROM python:3.9-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Install git and gosu for user switching
|
# Install git, gosu, and PowerShell Core
|
||||||
RUN apt-get update && apt-get install -y git gosu && rm -rf /var/lib/apt/lists/*
|
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 pre-built files from dist directory
|
||||||
COPY dist/backend/app ./app
|
COPY dist/backend/app ./app
|
||||||
|
COPY dist/backend/scripts ./app/scripts
|
||||||
COPY dist/static ./app/static
|
COPY dist/static ./app/static
|
||||||
COPY dist/requirements.txt .
|
COPY dist/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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 and setup entrypoint script
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
FROM python:3.9
|
FROM python:3.9
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Ensure scripts are executable
|
||||||
|
RUN chmod +x /app/scripts/*.ps1 || true
|
||||||
# Use gunicorn with 10-minute timeout
|
# Use gunicorn with 10-minute timeout
|
||||||
CMD ["python", "-m", "app.main"]
|
CMD ["python", "-m", "app.main"]
|
||||||
@@ -226,6 +226,32 @@ def handle_item(category, name):
|
|||||||
return jsonify({"error": "An unexpected error occurred"}), 500
|
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'])
|
@bp.route('/<string:category>/test', methods=['POST'])
|
||||||
def run_tests(category):
|
def run_tests(category):
|
||||||
logger.info(f"Received test request for category: {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 git
|
||||||
import regex
|
import regex
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
from ..db.queries.arr import update_arr_config_on_rename, update_arr_config_on_delete
|
from ..db.queries.arr import update_arr_config_on_rename, update_arr_config_on_delete
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)}"
|
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,
|
def update_references(category: str, old_name: str,
|
||||||
new_name: str) -> List[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) =>
|
update: (name, data, newName) =>
|
||||||
updateItem('regex_pattern', name, data, newName),
|
updateItem('regex_pattern', name, data, newName),
|
||||||
delete: name => deleteItem('regex_pattern', name),
|
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 PropTypes from 'prop-types';
|
||||||
import MarkdownEditor from '@ui/MarkdownEditor';
|
import MarkdownEditor from '@ui/MarkdownEditor';
|
||||||
import AddButton from '@ui/DataBar/AddButton';
|
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 = ({
|
const RegexGeneralTab = ({
|
||||||
name,
|
name,
|
||||||
@@ -18,6 +20,7 @@ const RegexGeneralTab = ({
|
|||||||
patternError
|
patternError
|
||||||
}) => {
|
}) => {
|
||||||
const [newTag, setNewTag] = useState('');
|
const [newTag, setNewTag] = useState('');
|
||||||
|
const [validating, setValidating] = useState(false);
|
||||||
|
|
||||||
const handleAddTag = () => {
|
const handleAddTag = () => {
|
||||||
if (newTag.trim() && !tags.includes(newTag.trim())) {
|
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 (
|
return (
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -89,17 +116,28 @@ const RegexGeneralTab = ({
|
|||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
<div>
|
||||||
Pattern
|
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
</label>
|
Pattern
|
||||||
<div className='flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400'>
|
</label>
|
||||||
<InfoIcon className='h-4 w-4' />
|
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
<span>Case insensitive PCRE2</span>
|
Enter your regular expression pattern (case-insensitive .NET)
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
Enter your regular expression pattern
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{patternError && (
|
{patternError && (
|
||||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||||
|
|||||||
@@ -65,6 +65,19 @@ export const useRegexModal = (initialPattern, onSave) => {
|
|||||||
return;
|
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 {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
name,
|
name,
|
||||||
|
|||||||
Reference in New Issue
Block a user