-
-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #17 from DeanWard/theming
Theming
- Loading branch information
Showing
33 changed files
with
5,568 additions
and
1,424 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
<?php | ||
|
||
namespace App\Http\Controllers; | ||
|
||
use Illuminate\Http\Request; | ||
use Illuminate\Support\Facades\Validator; | ||
use App\Models\Theme; | ||
|
||
class ThemesController extends Controller | ||
{ | ||
public function saveTheme(Request $request) | ||
{ | ||
$validator = Validator::make($request->all(), [ | ||
'name' => ['required', 'string', 'max:255'], | ||
'theme' => ['required', 'array'], | ||
]); | ||
|
||
if ($validator->fails()) { | ||
return response()->json([ | ||
'status' => 'error', | ||
'message' => 'Validation failed', | ||
'data' => [ | ||
'errors' => $validator->errors(), | ||
], | ||
], 422); | ||
} | ||
|
||
$themeConfig = $request->input('theme'); | ||
$themeName = $request->input('name'); | ||
|
||
$theme = Theme::where('name', $themeName)->first(); | ||
if ($theme) { | ||
$theme->theme = $themeConfig; | ||
$theme->save(); | ||
} else { | ||
$theme = Theme::create([ | ||
'name' => $themeName, | ||
'theme' => $themeConfig, | ||
]); | ||
} | ||
|
||
return response()->json([ | ||
'status' => 'success', | ||
'message' => 'Theme saved successfully', | ||
'data' => $theme, | ||
]); | ||
} | ||
|
||
public function installCustomTheme(Request $request) | ||
{ | ||
$validator = Validator::make($request->all(), [ | ||
'name' => ['required', 'string', 'max:255'], | ||
'file' => ['required', 'file'], | ||
]); | ||
|
||
if ($validator->fails()) { | ||
return response()->json([ | ||
'status' => 'error', | ||
'message' => 'Validation failed', | ||
'data' => [ | ||
'errors' => $validator->errors(), | ||
], | ||
], 422); | ||
} | ||
|
||
$themeName = $request->input('name'); | ||
$themeFile = $request->file('file'); | ||
|
||
$theme = Theme::where('name', $themeName)->first(); | ||
if ($theme) { | ||
$themeName = $themeName . ' (custom)'; | ||
} | ||
|
||
$theme = Theme::create([ | ||
'name' => $themeName, | ||
'category' => 'custom', | ||
'theme' => json_decode(file_get_contents($themeFile), true), | ||
]); | ||
|
||
return response()->json([ | ||
'status' => 'success', | ||
'message' => 'Theme installed successfully', | ||
'data' => $theme, | ||
]); | ||
} | ||
|
||
public function getThemes() | ||
{ | ||
$themes = Theme::orderBy('category', 'desc')->orderBy('bundled', 'desc')->get(); | ||
return response()->json([ | ||
'status' => 'success', | ||
'message' => 'Themes fetched successfully', | ||
'data' => [ | ||
'themes' => $themes, | ||
] | ||
]); | ||
} | ||
|
||
public function deleteTheme(Request $request) | ||
{ | ||
$validator = Validator::make($request->all(), [ | ||
'name' => ['required', 'string', 'max:255'], | ||
]); | ||
|
||
if ($validator->fails()) { | ||
return response()->json([ | ||
'status' => 'error', | ||
'message' => 'Validation failed', | ||
'data' => [ | ||
'errors' => $validator->errors(), | ||
], | ||
], 422); | ||
} | ||
|
||
$theme = Theme::where('name', $request->input('name'))->first(); | ||
if ($theme->active) { | ||
return response()->json([ | ||
'status' => 'error', | ||
'message' => 'Cannot delete active theme', | ||
], 400); | ||
} | ||
$theme->delete(); | ||
|
||
return response()->json([ | ||
'status' => 'success', | ||
'message' => 'Theme deleted successfully', | ||
]); | ||
} | ||
|
||
public function setActiveTheme(Request $request) | ||
{ | ||
$validator = Validator::make($request->all(), [ | ||
'name' => ['required', 'string', 'max:255'], | ||
]); | ||
|
||
if ($validator->fails()) { | ||
return response()->json([ | ||
'status' => 'error', | ||
'message' => 'Validation failed', | ||
'data' => [ | ||
'errors' => $validator->errors(), | ||
], | ||
], 422); | ||
} | ||
|
||
//find current active theme and set it to inactive | ||
$currentActiveTheme = Theme::where('active', true)->first(); | ||
if ($currentActiveTheme) { | ||
$currentActiveTheme->active = false; | ||
$currentActiveTheme->save(); | ||
} | ||
|
||
$theme = Theme::where('name', $request->input('name'))->first(); | ||
$theme->active = true; | ||
$theme->save(); | ||
|
||
|
||
|
||
return response()->json([ | ||
'status' => 'success', | ||
'message' => 'Theme set as active successfully', | ||
]); | ||
} | ||
|
||
public function getActiveTheme() | ||
{ | ||
$theme = Theme::where('active', true)->first(); | ||
return response()->json([ | ||
'status' => 'success', | ||
'message' => 'Active theme fetched successfully', | ||
'data' => [ | ||
'theme' => $theme, | ||
] | ||
]); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,210 @@ | ||
<?php | ||
|
||
namespace App\Models; | ||
|
||
use Illuminate\Database\Eloquent\Model; | ||
|
||
class Theme extends Model | ||
{ | ||
protected $fillable = ['name', 'theme', 'active', 'category']; | ||
|
||
protected $casts = [ | ||
'theme' => 'object', | ||
]; | ||
|
||
public function getThemeAttribute($value) | ||
{ | ||
|
||
// Make sure we're working with a proper object | ||
$rawTheme = $this->attributes['theme'] ?? '{}'; | ||
|
||
// Handle case where theme might be stored as a JSON string | ||
if (is_string($rawTheme)) { | ||
$theme = json_decode($rawTheme); | ||
if (json_last_error() !== JSON_ERROR_NONE) { | ||
// If JSON is invalid, return a default theme | ||
return $this->getDefaultTheme(); | ||
} | ||
} else { | ||
// If it's already an object, just use it | ||
$theme = json_decode(json_encode($rawTheme)); | ||
} | ||
|
||
// Helper function to sanitize CSS values | ||
$sanitizeCssValue = function ($value) { | ||
if (!is_string($value)) { | ||
return $value; | ||
} | ||
|
||
// Remove potentially dangerous patterns | ||
$dangerous = [ | ||
// JavaScript protocols | ||
'/javascript:/i', | ||
'/data:/i', | ||
// Function calls that could execute JS | ||
'/expression\s*\(/i', | ||
'/eval\s*\(/i', | ||
'/alert\s*\(/i', | ||
'/confirm\s*\(/i', | ||
'/prompt\s*\(/i', | ||
'/document\./i', | ||
'/window\./i', | ||
// CSS imports | ||
'/@import/i', | ||
// HTML tags | ||
'/<\/?[a-z][^>]*>/i', | ||
// Script injection | ||
'/<script>|<\/script>/i', | ||
// Event handlers | ||
'/on\w+\s*=/i', | ||
// Binding exploits | ||
'/-moz-binding/i', | ||
'/behavior\s*:/i', | ||
// Comment endings that could break out of comments | ||
'/\*\//i', | ||
// SQL injection patterns | ||
'/;\s*DROP\s+TABLE/i', | ||
// Various obfuscation techniques | ||
'/eval\s*\(/i', | ||
'/atob\s*\(/i', | ||
'/fetch\s*\(/i' | ||
]; | ||
|
||
foreach ($dangerous as $pattern) { | ||
$value = preg_replace($pattern, '[removed]', $value); | ||
} | ||
|
||
// Only allow specific patterns for gradients and colors | ||
if (preg_match('/^(#[0-9a-f]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-z-]+|linear-gradient\(([^()]|(\([^()]*\)))*\))$/i', $value)) { | ||
return $value; | ||
} | ||
|
||
// Only allow safe dimensions | ||
if (preg_match('/^[0-9]+(\.[0-9]+)?(%|px|rem|em|vh|vw|vmin|vmax)$/i', $value)) { | ||
return $value; | ||
} | ||
|
||
// Only allow safe URL references if they reference data schemes or known safe domains | ||
if (preg_match('/url\s*\(([^)]+)\)/i', $value, $matches)) { | ||
$url = trim($matches[1], '\'"'); | ||
// Only allow relative URLs or URLs to trusted domains | ||
if (strpos($url, '/') === 0 || strpos($url, './') === 0) { | ||
return "url('{$url}')"; | ||
} | ||
return '[url-removed]'; | ||
} | ||
|
||
// Only allow a subset of CSS functions | ||
$safeFunctions = [ | ||
'calc', | ||
'min', | ||
'max', | ||
'clamp', | ||
'var' | ||
]; | ||
|
||
foreach ($safeFunctions as $func) { | ||
if (preg_match('/^' . $func . '\s*\(([^()]|(\([^()]*\)))*\)$/i', $value)) { | ||
// Further sanitize the content inside these functions | ||
$sanitizedValue = preg_replace('/[^\w\s\-\.\,\(\)\#\%\/\:rgb\;]/i', '', $value); | ||
return $sanitizedValue; | ||
} | ||
} | ||
|
||
// For anything else, strictly filter to basic CSS characters | ||
return preg_replace('/[^\w\s\-\.\,\(\)\#\%\/\:rgb\;]/i', '', $value); | ||
}; | ||
|
||
// Recursive function to sanitize all values in the theme object | ||
$sanitizeThemeObject = function (&$obj) use (&$sanitizeThemeObject, $sanitizeCssValue) { | ||
if (!is_object($obj) && !is_array($obj)) { | ||
return $sanitizeCssValue($obj); | ||
} | ||
|
||
foreach ($obj as $key => &$value) { | ||
if (is_object($value) || is_array($value)) { | ||
$sanitizeThemeObject($value); | ||
} else { | ||
$value = $sanitizeCssValue($value); | ||
} | ||
} | ||
return $obj; | ||
}; | ||
|
||
// Sanitize the entire theme object | ||
$sanitizeThemeObject($theme); | ||
|
||
// Validate mandatory structure to prevent missing elements | ||
if ( | ||
!isset($theme->links) || !isset($theme->buttons) || | ||
!isset($theme->buttons->primary) || !isset($theme->buttons->secondary) | ||
) { | ||
|
||
// Merge with defaults to fill missing parts | ||
$theme = (object) array_merge((array) $this->getDefaultTheme(), (array) $theme); | ||
} | ||
|
||
return $theme; | ||
} | ||
|
||
/** | ||
* Get default theme structure when theme is invalid or missing parts | ||
*/ | ||
private function getDefaultTheme() | ||
{ | ||
return (object) [ | ||
'links' => (object) [ | ||
'default' => 'rgb(187, 134, 252)', | ||
'hover' => 'rgb(203, 166, 247)', | ||
'active' => 'rgb(221, 195, 255)', | ||
'disabled' => 'rgba(190, 190, 190, 0.4)' | ||
], | ||
'buttons' => (object) [ | ||
'primary' => (object) [ | ||
'default' => (object) [ | ||
'background' => 'linear-gradient(135deg, rgb(137, 87, 229) 0%, rgb(156, 113, 232) 100%)', | ||
'text' => 'rgb(255, 255, 255)', | ||
'boxShadow' => '0 2px 8px rgba(137, 87, 229, 0.3)' | ||
], | ||
'hover' => (object) [ | ||
'background' => 'linear-gradient(135deg, rgb(156, 113, 232) 0%, rgb(174, 137, 238) 100%)', | ||
'text' => 'rgb(255, 255, 255)', | ||
'boxShadow' => '0 3px 10px rgba(137, 87, 229, 0.4)' | ||
], | ||
'active' => (object) [ | ||
'background' => 'linear-gradient(135deg, rgb(174, 137, 238) 0%, rgb(187, 154, 242) 100%)', | ||
'text' => 'rgb(255, 255, 255)', | ||
'boxShadow' => '0 2px 6px rgba(137, 87, 229, 0.3)' | ||
], | ||
'disabled' => (object) [ | ||
'background' => 'linear-gradient(135deg, rgba(137, 87, 229, 0.4) 0%, rgba(156, 113, 232, 0.4) 100%)', | ||
'text' => 'rgba(255, 255, 255, 0.4)', | ||
'boxShadow' => 'none' | ||
] | ||
], | ||
'secondary' => (object) [ | ||
'default' => (object) [ | ||
'background' => 'linear-gradient(135deg, rgb(44, 44, 52) 0%, rgb(50, 50, 60) 100%)', | ||
'text' => 'rgb(220, 220, 220)', | ||
'boxShadow' => '0 2px 6px rgba(0, 0, 0, 0.2)' | ||
], | ||
'hover' => (object) [ | ||
'background' => 'linear-gradient(135deg, rgb(56, 56, 66) 0%, rgb(62, 62, 74) 100%)', | ||
'text' => 'rgb(230, 230, 230)', | ||
'boxShadow' => '0 3px 8px rgba(0, 0, 0, 0.25)' | ||
], | ||
'active' => (object) [ | ||
'background' => 'linear-gradient(135deg, rgb(66, 66, 78) 0%, rgb(72, 72, 86) 100%)', | ||
'text' => 'rgb(240, 240, 240)', | ||
'boxShadow' => '0 2px 4px rgba(0, 0, 0, 0.2)' | ||
], | ||
'disabled' => (object) [ | ||
'background' => 'linear-gradient(135deg, rgba(50, 50, 50, 0.5) 0%, rgba(60, 60, 60, 0.5) 100%)', | ||
'text' => 'rgba(200, 200, 200, 0.4)', | ||
'boxShadow' => 'none' | ||
] | ||
] | ||
] | ||
]; | ||
} | ||
} |
Oops, something went wrong.