Current state

This commit is contained in:
2026-02-07 08:23:18 +01:00
commit 0a4372c40d
22479 changed files with 1553543 additions and 0 deletions

310
.copilot/api-first.md Normal file
View File

@@ -0,0 +1,310 @@
# API-First Architecture Canonical Rules (SkinBase)
> **This document defines how Copilot must generate API-first code.**
> It applies to **Artworks and all future modules**.
> If generated code conflicts with this file, **this file wins**.
---
## 1. What “API-First” Means (MANDATORY)
API-first means:
* Business logic lives in **Services**
* Controllers are **thin adapters**
* Output is defined by **API Resources**
* Web (Blade), API, Admin all use **the same services**
* No duplicated logic between web & API
Copilot MUST assume:
* Web UI exists (Blade / SSR)
* API exists (JSON)
* Both consume the **same backend logic**
---
## 2. Layered Architecture (STRICT)
Copilot MUST generate code following this flow:
```
Request
→ Controller (Web or API)
→ Service (business rules)
→ Models / Queries
→ Resource (output shape)
```
### Forbidden shortcuts
❌ Controller → Model directly
❌ Controller → DB query
❌ Resource contains logic
❌ Model contains business logic
---
## 3. Directory Structure (REFERENCE)
Copilot MUST follow this structure:
```
app/
├── Http/
│ ├── Controllers/
│ │ ├── Api/
│ │ │ └── ArtworkController.php
│ │ └── Web/
│ │ └── ArtworkController.php
│ │
│ ├── Resources/
│ │ ├── ArtworkResource.php
│ │ ├── ArtworkListResource.php
│ │ └── CategoryResource.php
├── Services/
│ ├── ArtworkService.php
│ ├── ArtworkStatsService.php
│ └── CategoryService.php
├── Policies/
│ └── ArtworkPolicy.php
├── Models/
│ └── Artwork.php
```
---
## 4. Services Layer (CORE)
### 4.1 ArtworkService (MANDATORY)
Copilot MUST generate an `ArtworkService` that handles:
* Fetch public artwork by slug
* Fetch artworks by category
* Apply visibility rules
* Apply soft delete rules
* Throw domain-appropriate exceptions
Example responsibilities (NOT code):
* `getPublicArtworkBySlug(string $slug)`
* `getCategoryArtworks(Category $category)`
* `getLatestArtworks(int $limit)`
### Rules
* Services MUST NOT return JSON
* Services MUST return models or collections
* Services MUST enforce visibility rules
---
## 5. Controllers (ADAPTERS ONLY)
### 5.1 API Controllers
Location:
```
app/Http/Controllers/Api/
```
Rules:
* Return API Resources only
* Never return models directly
* No business logic
* Stateless
Example:
```php
return new ArtworkResource(
$this->service->getPublicArtworkBySlug($slug)
);
```
---
### 5.2 Web Controllers
Location:
```
app/Http/Controllers/Web/
```
Rules:
* Use same services as API
* Prepare data for Blade
* No duplication of logic
* SEO handled here (meta tags, schema)
Example:
```php
return view('artworks.show', [
'artwork' => new ArtworkResource($artwork),
]);
```
---
## 6. API Resources (OUTPUT CONTRACT)
### Rules
Copilot MUST:
* Use Laravel `JsonResource`
* Define explicit fields
* Never expose internal fields accidentally
* Avoid N+1 queries
* Include relations conditionally
### ArtworkResource MUST include:
* slug
* title
* description
* dimensions
* categories
* URLs (canonical)
### ArtworkListResource MUST:
* Be lightweight
* Exclude heavy relations
* Exclude stats unless requested
---
## 7. Routes (SEO-Safe)
### API routes
Location:
```
routes/api.php
```
Rules:
* Stateless
* Slug-based
* Versionable (`/api/v1/...`)
Example:
```
GET /api/v1/artworks/{slug}
GET /api/v1/categories/{slug}/artworks
```
---
### Web routes
Location:
```
routes/web.php
```
Rules:
* Slug-based
* No IDs
* SEO-friendly
* SSR output
---
## 8. Stats Handling (High Load Rule)
Copilot MUST:
* Use `ArtworkStatsService`
* Increment stats via Jobs
* Never mutate counters inline
* Assume Redis may be present
Forbidden:
`$artwork->increment('views')`
❌ Updating stats inside controllers
---
## 9. Caching Rules
Copilot SHOULD assume:
* Redis is available
* Cache keys are service-level
* Resources are cacheable
Examples:
* `artwork:{slug}`
* `category:{slug}:artworks`
Cache invalidation:
* On update
* On delete
* On restore
---
## 10. Error Handling Rules
Copilot MUST:
* Return 404 for missing public content
* Return 410 or 301 for soft-deleted content (if requested)
* Never expose private content via API
---
## 11. Testing Philosophy
Copilot MUST generate tests that:
* Hit API endpoints
* Validate JSON structure
* Test visibility & approval
* Do NOT test Blade HTML
---
## 12. Forbidden Patterns (ABSOLUTE)
❌ Controllers with logic
❌ Models with business rules
❌ Duplicate logic between API & Web
❌ Direct DB queries in controllers
❌ Different rules for API vs Web
---
## 13. Final Instruction (NON-NEGOTIABLE)
> **API is the primary contract.**
> Web UI, Admin UI, Mobile apps are **clients**.
Copilot MUST always ask:
> “Can this logic live in a service?”
If yes → put it there.
---
### ✅ End of API-First Architecture Instructions

370
.copilot/artworks.md Normal file
View File

@@ -0,0 +1,370 @@
# Artworks Module Canonical Architecture (SkinBase)
> **Authoritative documentation for Copilot AI agent**
> This file defines the **single source of truth** for the `artworks` domain.
> All generated code **MUST follow this document**.
---
## 1. Purpose
The **Artworks module** is the core content system of SkinBase.
It must support:
* high-read traffic (browse/search)
* safe moderation (soft deletes, approvals)
* multilingual content
* SEO-friendly URLs
* scalable statistics
* future extensions (tags, EXIF, search engines)
Legacy tables **must NOT influence new code**.
---
## 2. Core Design Principles (DO NOT VIOLATE)
1. **Single responsibility per table**
2. **No counters on hot tables**
3. **Soft deletes on user-generated content**
4. **No legacy fields**
5. **Slug-based routing only**
6. **FK integrity everywhere**
7. **Indexes optimized for browsing**
8. **Stats updated asynchronously**
---
## 3. Database Schema (Canonical)
### 3.1 `artworks` (CORE TABLE)
**Purpose:**
Stores the authoritative artwork entity.
```sql
artworks
```
**Fields:**
| Field | Type | Notes |
| ------------ | ------------ | ---------------- |
| id | bigint | Primary key |
| user_id | bigint | Owner |
| title | varchar(150) | Default language |
| slug | varchar(160) | UNIQUE, URL |
| description | text | Optional |
| file_name | varchar | Original file |
| file_path | varchar | Storage path |
| file_size | bigint | Bytes |
| mime_type | varchar(64) | e.g. image/jpeg |
| width | int | Pixels |
| height | int | Pixels |
| is_public | boolean | Visibility |
| is_approved | boolean | Moderation |
| published_at | datetime | SEO timing |
| created_at | datetime | |
| updated_at | datetime | |
| deleted_at | datetime | Soft delete |
**Indexes:**
* `UNIQUE(slug)`
* `(is_public, is_approved, published_at)`
* `deleted_at`
---
### 3.2 `artwork_translations`
**Purpose:**
Multilingual titles and descriptions.
```sql
artwork_translations
```
| Field | Type |
| ----------- | -------- |
| artwork_id | FK |
| locale | char(2) |
| title | varchar |
| description | text |
| deleted_at | datetime |
**Rules:**
* One row per `(artwork_id, locale)`
* Default language lives in `artworks`
---
### 3.3 `artwork_stats`
**Purpose:**
High-write counters isolated from core table.
```sql
artwork_stats
```
| Field | Type |
| ------------ | ------- |
| artwork_id | PK + FK |
| views | bigint |
| downloads | bigint |
| favorites | bigint |
| rating_avg | float |
| rating_count | int |
**Rules:**
* NO soft deletes
* Updated via jobs / async
* Never eager-loaded by default
---
### 3.4 `artwork_category`
**Purpose:**
Many-to-many relation with categories.
```sql
artwork_category
```
| Field | Type |
| ----------- | ---- |
| artwork_id | FK |
| category_id | FK |
**Rules:**
* Categories handle hierarchy
* Artworks can belong to multiple categories
---
### 3.5 `artwork_comments`
**Purpose:**
User comments with moderation.
```sql
artwork_comments
```
| Field | Type |
| ----------- | -------- |
| artwork_id | FK |
| user_id | FK |
| content | text |
| is_approved | boolean |
| deleted_at | datetime |
---
### 3.6 `artwork_downloads`
**Purpose:**
Audit log of downloads.
```sql
artwork_downloads
```
| Field | Type |
| ---------- | ---------- |
| artwork_id | FK |
| user_id | nullable |
| ip | binary(16) |
| user_agent | varchar |
| created_at | datetime |
**Rules:**
* Append-only
* No soft deletes
* Used for abuse detection & stats aggregation
---
## 4. Eloquent Models (REQUIRED)
### 4.1 Artwork Model
```php
App\Models\Artwork
```
**Traits:**
* `SoftDeletes`
**Relationships:**
```php
belongsTo(User::class)
hasMany(ArtworkTranslation::class)
hasOne(ArtworkStats::class)
belongsToMany(Category::class)
hasMany(ArtworkComment::class)
hasMany(ArtworkDownload::class)
```
**Required Scopes:**
```php
public function scopePublic($q)
public function scopeApproved($q)
public function scopePublished($q)
```
---
### 4.2 ArtworkTranslation
```php
App\Models\ArtworkTranslation
```
* SoftDeletes
* BelongsTo Artwork
---
### 4.3 ArtworkStats
```php
App\Models\ArtworkStats
```
* No SoftDeletes
* BelongsTo Artwork
---
### 4.4 ArtworkComment
```php
App\Models\ArtworkComment
```
* SoftDeletes
* BelongsTo Artwork
* BelongsTo User
---
### 4.5 ArtworkDownload
```php
App\Models\ArtworkDownload
```
* Append-only
* BelongsTo Artwork
---
## 5. Query Rules (IMPORTANT)
### Public browsing MUST always include:
```sql
WHERE
deleted_at IS NULL
AND is_public = 1
AND is_approved = 1
```
### NEVER:
* eager-load stats on lists
* update counters inline
* expose IDs in URLs
---
## 6. Routing Rules
### Canonical URLs
```
/{content_type}/{category_path}/{artwork_slug}
```
Example:
```
/photography/abstract/dark/night-city
```
### Slug uniqueness is GLOBAL.
---
## 7. Search Rules
* Use MySQL FULLTEXT as fallback
* Prefer external search engines later
* Never search on `file_name` or paths
---
## 8. Caching Rules
* Category listings → Redis
* Homepage feeds → Redis
* Artwork stats → cached
* DB is source of truth
---
## 9. Soft Delete Behavior
| Action | Result |
| ------------------- | ------------------ |
| Soft delete artwork | Hidden from public |
| Restore | Fully restored |
| Force delete | Rare, GDPR only |
SEO:
* Soft-deleted artworks → 410 or 301
---
## 10. Forbidden Patterns (NEVER GENERATE)
❌ Counters on `artworks`
❌ IDs in URLs
❌ Hard deletes
❌ Category logic inside artworks
❌ Mixed language columns
❌ MyISAM
❌ Polymorphic abuse
---
## 11. Future Extensions (Allowed)
Copilot MAY extend with:
* `artwork_tags`
* `artwork_exif`
* `artwork_versions`
* `artwork_reports`
* external search engines
BUT must not modify core tables without migrations.
---
## 12. Final Rule (MANDATORY)
> **If generated code conflicts with this document,
> THIS DOCUMENT WINS.**

259
.copilot/categories.md Normal file
View File

@@ -0,0 +1,259 @@
Nice, this is a **perfect use-case for Copilot AI Agent** 👍
Below is a **clean, copy-paste ready Markdown file** you can put straight into your repo (e.g. `.copilot/categories.md`) and tell Copilot to follow.
This is written **explicitly for an AI agent**: clear goals, constraints, SQL, Laravel expectations.
---
````markdown
# SkinBase Category System (NEW SQL Structure)
This document defines the **new category & taxonomy system** for SkinBase.
Copilot AI Agent must follow this structure strictly and MUST NOT reuse legacy logic.
---
## 🎯 Goals
- SEO-friendly URLs (no IDs in public routes)
- Clear separation of content types (Photography, Skins, Wallpapers, etc.)
- Unlimited category nesting
- Laravel-friendly (Eloquent, migrations, relations)
- Ready for sitemap, breadcrumbs, translations
---
## 🚫 Legacy System (DO NOT USE)
The old table `artworks_categories` is deprecated.
DO NOT:
- use `section_id`
- use `rootid`
- use `num_artworks`
- expose IDs in URLs
- infer hierarchy from numeric hacks
---
## ✅ New Database Structure
### 1⃣ content_types
Top-level sections (URL level 1)
```sql
CREATE TABLE content_types (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
slug VARCHAR(64) NOT NULL UNIQUE,
description TEXT NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
) ENGINE=InnoDB;
````
Examples:
* Photography → `photography`
* Skins → `skins`
* Wallpapers → `wallpapers`
Used in URLs as:
```
/photography
/skins
/wallpapers
```
---
### 2⃣ categories
Hierarchical categories (unlimited depth)
```sql
CREATE TABLE categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
content_type_id INT UNSIGNED NOT NULL,
parent_id INT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
slug VARCHAR(128) NOT NULL,
description TEXT NULL,
image VARCHAR(255) NULL,
is_active BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY uniq_category_slug (content_type_id, slug),
FOREIGN KEY (content_type_id) REFERENCES content_types(id),
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE CASCADE
) ENGINE=InnoDB;
```
Hierarchy example:
```
Photography
├── Abstract
├── Comic
└── Dark
```
---
### 3⃣ artwork_category (pivot)
```sql
CREATE TABLE artwork_category (
artwork_id INT UNSIGNED NOT NULL,
category_id INT UNSIGNED NOT NULL,
PRIMARY KEY (artwork_id, category_id),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
) ENGINE=InnoDB;
```
---
## 🧭 Routing Rules (MANDATORY)
Public URLs MUST use slugs only.
### Category pages
```
/{content_type}
/{content_type}/{category}
/{content_type}/{parent-category}/{category}
```
Examples:
```
/photography
/photography/abstract
/skins/desktop-shell
```
### Artwork pages
```
/photography/abstract/fresh-red-apple
/skins/darkstep/minimal-dark-pro
```
❌ Never expose numeric IDs in URLs.
---
## 🧠 Laravel Eloquent Relations
Copilot MUST generate models with these relations:
```php
ContentType hasMany Categories
Category belongsTo ContentType
Category hasMany Categories (children)
Category belongsTo Category (parent)
Artwork belongsToMany Categories
```
---
## 🔍 Querying Logic
Lookup must be done by slug, not ID.
Example:
```php
$contentType = ContentType::whereSlug($type)->firstOrFail();
$category = Category::whereSlug($slug)
->where('content_type_id', $contentType->id)
->firstOrFail();
```
---
## 📈 SEO Rules
* One canonical URL per category
* Breadcrumbs must reflect hierarchy
* Pagination via `?page=X`
* Filters MUST use query params and be `noindex`
---
## 📦 Migration
Legacy data has already been converted.
Migration SQL file:
```
/mnt/data/skinbase_categories_migration.sql
```
Copilot must NOT regenerate category data.
---
## ❗ Hard Rules for AI Agent
* DO NOT invent new tables
* DO NOT mix legacy and new schema
* DO NOT store counters in categories
* DO NOT assume only 2 levels of depth
* ALWAYS use slugs
---
## ✅ Result Expectation
After integration:
* SEO-clean URLs
* Stable taxonomy
* Easy sitemap generation
* Future-ready for translations
END OF SPEC
```
---
## ✅ How to use this
1. Save as:
```
.copilot/skinbase-categories.md
```
2. In VS Code Copilot Chat:
```
Use .copilot/skinbase-categories.md as the authoritative spec.
Integrate category system accordingly.
```
---
If you want next, I can:
- ✍️ generate **Laravel migrations**
- 🧠 generate **Eloquent models**
- 🧭 generate **routes + controllers**
- 🔁 generate **301 redirect mapping**
- 🗺 generate **XML sitemap logic**
Just tell me what Copilot should build next 🚀
```

293
.copilot/inctructions.md Normal file
View File

@@ -0,0 +1,293 @@
# Copilot Instructions Artworks Module (SkinBase)
> **This file defines HOW Copilot must generate code.**
> It is a strict instruction set.
> If there is a conflict between generated code and these rules,
> **these rules override everything.**
---
## 1. Global Rules (MANDATORY)
Copilot MUST:
* Target **Laravel 12**
* Use **PHP 8.3+ syntax**
* Follow **Eloquent best practices**
* Respect **SoftDeletes**
* Respect **FK relationships**
* Generate **clean, readable, maintainable code**
Copilot MUST NOT:
* Reference legacy tables (`wallz`, old categories, old views)
* Generate MyISAM tables
* Generate hard deletes for user content
* Put counters on hot tables
* Use IDs in URLs
* Mix responsibilities across models
---
## 2. Authoritative Schema Reference
The canonical schema for artworks is defined in:
```
.copilot/artworks.md
```
Copilot MUST:
* Read this file before generating any code
* Match table names, columns, relations exactly
* Never invent fields or tables unless explicitly allowed
---
## 3. Models Generation Rules
When generating Eloquent models:
### Required models
Copilot MUST generate:
* `App\Models\Artwork`
* `App\Models\ArtworkTranslation`
* `App\Models\ArtworkStats`
* `App\Models\ArtworkComment`
* `App\Models\ArtworkDownload`
### Model requirements
Each model MUST:
* Declare `$fillable`
* Define all relationships
* Use `SoftDeletes` **only when allowed**
* Include PHPDoc blocks for relations
* Use type-hinted return values
### Forbidden
Copilot MUST NOT:
* Add business logic into models
* Perform stat mutations in models
* Use observers unless explicitly requested
---
## 4. Relationships (STRICT)
Copilot MUST implement these exact relations:
### Artwork
```php
belongsTo(User::class)
hasMany(ArtworkTranslation::class)
hasOne(ArtworkStats::class)
belongsToMany(Category::class)
hasMany(ArtworkComment::class)
hasMany(ArtworkDownload::class)
```
### ArtworkTranslation
```php
belongsTo(Artwork::class)
```
### ArtworkStats
```php
belongsTo(Artwork::class)
```
### ArtworkComment
```php
belongsTo(Artwork::class)
belongsTo(User::class)
```
### ArtworkDownload
```php
belongsTo(Artwork::class)
```
---
## 5. Query & Scope Rules
Copilot MUST define these scopes on `Artwork`:
```php
scopePublic($query)
scopeApproved($query)
scopePublished($query)
```
Public queries MUST always include:
```php
deleted_at IS NULL
is_public = true
is_approved = true
```
Copilot MUST NOT:
* Eager-load stats in list views
* Use `offset` pagination for feeds
* Load unnecessary relations by default
---
## 6. Controller Generation Rules
When generating controllers:
Copilot MUST:
* Use **thin controllers**
* Delegate logic to services/actions if needed
* Validate input using Form Requests
* Use route-model binding with `slug`
* Handle soft-deleted content properly
Copilot MUST NOT:
* Query raw DB tables directly
* Bypass scopes
* Return unfiltered content
---
## 7. Routing Rules
Routes MUST:
* Use **slug-based routing**
* Never expose numeric IDs
* Respect category hierarchy
* Be SEO-friendly
Example (valid):
```
/photography/abstract/dark/night-city
```
Invalid:
```
/artwork/123
```
---
## 8. Soft Delete Rules
Copilot MUST:
* Use `delete()` (soft delete) for user content
* Use `restore()` for recovery
* Use `forceDelete()` **only** when explicitly requested
When content is soft-deleted:
* It must disappear from public browsing
* It must remain accessible to admins
---
## 9. Stats & High-Load Rules
Copilot MUST:
* Treat stats as **derived data**
* Update stats via jobs / services
* Never increment counters inline in controllers
* Assume Redis may be present
Copilot MUST NOT:
* Store counters on `artworks`
* Use `increment()` directly on hot rows
---
## 10. Search Rules
Copilot MAY:
* Use MySQL FULLTEXT
* Use external search engines (if requested)
Copilot MUST NOT:
* Search file paths
* Search binary metadata
* Assume Elasticsearch exists unless specified
---
## 11. Forbidden Patterns (NEVER GENERATE)
❌ Hard deletes on artworks
❌ Legacy column names
❌ Polymorphic abuse
❌ Fat controllers
❌ Magic numbers
❌ Inline SQL in controllers
❌ Business logic in migrations
---
## 12. Extension Rules
Copilot MAY generate new features ONLY if:
* They do not modify core tables
* They follow the same architectural principles
* They are isolated in new tables/services
Allowed examples:
* tags
* EXIF metadata
* versioning
* reporting/flagging
* API resources
---
## 13. Error Handling
Copilot MUST:
* Throw meaningful exceptions
* Return proper HTTP codes
* Use 404 for missing public content
* Use 410 or 301 for deleted content (if requested)
---
## 14. Final Instruction (ABSOLUTE)
> **Copilot must treat this file and `artworks.md`
> as non-negotiable contracts.**
If unsure:
* ask for clarification
* do NOT guess
* do NOT invent schema
---
### ✅ End of Copilot Instructions

View File

@@ -0,0 +1,816 @@
# SkinBase Legacy Users Migration to Laravel Auth (Authoritative Spec)
This document is the **single source of truth** for migrating users from the legacy SkinBase database (`projekti_old_skinbase`) into the new Laravel (SkinBase 2026) application using modern authentication.
It covers:
- ✅ Target data model (Laravel-friendly)
- ✅ What to keep / what to drop from legacy
- ✅ Exact Laravel migrations
- ✅ Import SQL + migration order
- ✅ Old password compatibility (if possible)
- ✅ First-login password reset flow (recommended)
- ✅ Validation SQL & sanity checks
- ✅ Role-based access control (RBAC)
> **Copilot AI Agent instructions:**
> Follow this document strictly. Do not invent additional fields/tables unless explicitly allowed here.
---
## 0) Context: Legacy tables (what were migrating)
Legacy schema relevant for users:
### `users` (MyISAM, mixed responsibilities)
- `user_id` (PK)
- `uname` (username)
- `password` (varchar 80) legacy hash or plaintext (unknown)
- `password2` (varchar 255) sometimes present
- `email`
- `real_name`
- `web`
- `birth`, `gender`, `country`, `country_code`, `lang`
- `picture`, `cover_art`
- `signature`, `about_me`, `description`
- `LastVisit`, `joinDate`
- `user_type` (membership / level)
- `active`, `authorized`
- many legacy preferences and obsolete fields (ICQ etc.)
### `users_data` (mostly duplicate/overlap)
This is redundant and will NOT be kept as-is.
### `users_statistics`
Useful but not auth-related; will migrate to `user_statistics`.
### `users_types`
Legacy user “levels”. Well map to modern roles.
---
## 1) Migration Goals
### Authentication goals
- Use **Laravel default authentication** (Breeze/Fortify/Jetstream-compatible).
- Allow login via:
- username OR email
- Preserve user accounts with minimal friction.
- Handle legacy password format safely:
- Prefer secure migration with password reset
- Optionally support legacy hash verification if algorithm is known
### Data goals
- Keep IDs stable where reasonable (`user_id``users.id`) to simplify future migrations.
- Move non-auth profile data into a dedicated profile table.
- Remove obsolete fields (ICQ etc.) and replace with modern social links.
### Security goals
- Do not store weak hashes long-term.
- If legacy password verification is implemented, rehash to bcrypt/argon immediately upon successful login.
- Default to forcing password reset if legacy hash format is unknown.
---
## 2) Target Database Design (New System)
### 2.1 `users` (Auth + identity only)
**Keep it clean.** This table should contain only identity/auth/security-critical fields.
Fields:
- `id` (BIGINT)
- `username` (unique)
- `name`
- `email` (unique)
- `password` (bcrypt/argon hash)
- `email_verified_at`
- `remember_token`
- `is_active` (legacy `active`)
- `needs_password_reset` (new)
- `role` (simple RBAC) OR use roles table/spatie later
- timestamps
### 2.2 `user_profiles` (Profile data)
- bio/about, avatar, cover image
- country + language + birthdate + gender
- website
- timestamps
### 2.3 `user_social_links` (modern social replacement)
Instead of ICQ, store dynamic social platforms:
- github, twitter/x, instagram, youtube, discord, website, etc.
### 2.4 `user_statistics` (optional but useful)
Migrated from legacy `users_statistics`.
---
## 3) What to Remove / Replace
### Remove (obsolete / not used / legacy UI junk)
- `icq` (obsolete)
- `zone`
- `numboard`, `NumStats`, `numskin`, `section_style`
- `menu`
- `eicon`
- `mlist`
- various “board/menu” preferences that no longer exist
### Keep / migrate
- username, email, name
- last visit (optional)
- active/authorized → `is_active` + `email_verified_at` strategy
- about/bio
- avatar/cover
- country/language/gender/birthdate
- website
- statistics (optional)
### Replace ICQ with social links
Add `user_social_links` table.
---
## 4) Role Mapping (Legacy `users_types` → Modern RBAC)
Legacy:
- `users.user_type` references `users_types.id`
New (simple approach):
- store a string `role` directly in `users.role`:
- `user`
- `moderator`
- `admin`
Mapping recommendation (adjust if your legacy meaning differs):
- `user_type` <= 0 → `user`
- `user_type` in [1..X] with “moderator” meaning → `moderator`
- special admin IDs → `admin`
> If you later need granular permissions, adopt **spatie/laravel-permission**.
> For now, keep it simple.
---
## 5) Exact Laravel Migrations (Copy/Paste)
> These migrations are authoritative. Put them in `database/migrations/` in this order.
### 5.1 Create/extend `users` table
If you already have Laravels default `users` migration, create a new migration to **modify** it.
**Migration: `2026_02_01_000010_update_users_table_for_skinbase.php`**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Ensure big integer id is used in your app; Laravel default is bigIncrements already.
// Add username for legacy uname
if (!Schema::hasColumn('users', 'username')) {
$table->string('username', 80)->nullable()->unique()->after('id');
}
// If name exists, keep it. Ensure nullable for legacy.
if (!Schema::hasColumn('users', 'name')) {
$table->string('name')->nullable();
} else {
$table->string('name')->nullable()->change();
}
// Email is important; legacy might have duplicates/NULLs -> handle in import script carefully.
if (Schema::hasColumn('users', 'email')) {
$table->string('email')->nullable()->change();
}
if (!Schema::hasColumn('users', 'is_active')) {
$table->boolean('is_active')->default(true)->after('remember_token');
}
if (!Schema::hasColumn('users', 'needs_password_reset')) {
$table->boolean('needs_password_reset')->default(true)->after('is_active');
}
if (!Schema::hasColumn('users', 'role')) {
$table->string('role', 32)->default('user')->after('needs_password_reset');
}
// Optional: store legacy hash algorithm marker (only if doing compat)
if (!Schema::hasColumn('users', 'legacy_password_algo')) {
$table->string('legacy_password_algo', 32)->nullable()->after('role');
}
// Optional: store legacy last visit
if (!Schema::hasColumn('users', 'last_visit_at')) {
$table->timestamp('last_visit_at')->nullable()->after('legacy_password_algo');
}
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'username')) $table->dropColumn('username');
if (Schema::hasColumn('users', 'is_active')) $table->dropColumn('is_active');
if (Schema::hasColumn('users', 'needs_password_reset')) $table->dropColumn('needs_password_reset');
if (Schema::hasColumn('users', 'role')) $table->dropColumn('role');
if (Schema::hasColumn('users', 'legacy_password_algo')) $table->dropColumn('legacy_password_algo');
if (Schema::hasColumn('users', 'last_visit_at')) $table->dropColumn('last_visit_at');
});
}
};
````
---
### 5.2 Create `user_profiles`
**Migration: `2026_02_01_000020_create_user_profiles_table.php`**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('user_profiles', function (Blueprint $table) {
$table->unsignedBigInteger('user_id')->primary();
$table->text('bio')->nullable();
$table->string('avatar', 255)->nullable();
$table->string('cover_image', 255)->nullable();
$table->string('country', 80)->nullable();
$table->char('country_code', 2)->nullable(); // normalize to ISO-3166-1 alpha-2
$table->string('language', 10)->nullable();
$table->date('birthdate')->nullable();
$table->enum('gender', ['M','F','X'])->default('X');
$table->string('website', 255)->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('user_profiles');
}
};
```
---
### 5.3 Create `user_social_links`
**Migration: `2026_02_01_000030_create_user_social_links_table.php`**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('user_social_links', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('platform', 32); // e.g. github, twitter, instagram, youtube, discord, website
$table->string('url', 255);
$table->timestamps();
$table->unique(['user_id', 'platform']);
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('user_social_links');
}
};
```
---
### 5.4 Create `user_statistics` (optional)
**Migration: `2026_02_01_000040_create_user_statistics_table.php`**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('user_statistics', function (Blueprint $table) {
$table->unsignedBigInteger('user_id')->primary();
$table->unsignedInteger('uploads')->default(0);
$table->unsignedInteger('downloads')->default(0);
$table->unsignedInteger('pageviews')->default(0);
$table->unsignedInteger('awards')->default(0);
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('user_statistics');
}
};
```
---
## 6) Auth Logic: Old Password Compatibility (Two Approaches)
### Approach A (Recommended): Force Reset for Everyone
**Safest**, because legacy hashing is unknown.
* Import users with a random password hash (or keep legacy hash in `password` temporarily but do not allow it).
* Set `needs_password_reset = 1`
* On login attempt, require reset.
This avoids accepting weak hashes forever.
### Approach B (Optional): Support legacy hash on first login, then rehash
Only do this if you can identify the algorithm used in legacy `password` / `password2`.
Common old hashes:
* MD5: 32 hex chars
* SHA1: 40 hex chars
* bcrypt: starts with `$2y$` or `$2a$`
* phpBB style or custom salts: unknown
#### Detection hints (informational)
* If legacy `password` length is 32 and is hex → probably MD5
* If length is 40 hex → probably SHA1
* If starts with `$2y$` → already bcrypt
> **Action requirement:** Before implementing compat, confirm by checking a known user password against a sample hash.
---
## 7) Implementation: Login via Username OR Email
### 7.1 Breeze/Fortify login request logic
In your login controller (or Fortify authentication callback), accept a single input field:
* `login` (username or email)
* `password`
Example lookup:
```php
$user = User::query()
->where('email', $login)
->orWhere('username', $login)
->first();
```
---
## 8) Implementation: Legacy Password Compatibility (If enabled)
### 8.1 Add a service: `app/Support/LegacyPassword.php`
```php
<?php
namespace App\Support;
class LegacyPassword
{
public static function detectAlgo(?string $hash): ?string
{
if (!$hash) return null;
if (str_starts_with($hash, '$2y$') || str_starts_with($hash, '$2a$')) return 'bcrypt';
if (preg_match('/^[a-f0-9]{32}$/i', $hash)) return 'md5';
if (preg_match('/^[a-f0-9]{40}$/i', $hash)) return 'sha1';
return null; // unknown
}
public static function verify(string $plain, string $legacyHash, string $algo): bool
{
return match ($algo) {
'md5' => md5($plain) === $legacyHash,
'sha1' => sha1($plain) === $legacyHash,
'bcrypt' => password_verify($plain, $legacyHash),
default => false
};
}
}
```
### 8.2 Modify authentication (pseudo-code)
On login:
1. Try normal Laravel hash check (`Hash::check`)
2. If fails AND `legacy_password_algo` is present (or detected), try legacy verify
3. If legacy verify passes:
* set new password using `Hash::make($plain)`
* set `needs_password_reset = 0`
* clear `legacy_password_algo`
Example snippet:
```php
use Illuminate\Support\Facades\Hash;
use App\Support\LegacyPassword;
if (Hash::check($password, $user->password)) {
// ok
} else {
$algo = $user->legacy_password_algo ?: LegacyPassword::detectAlgo($user->password);
if ($algo && LegacyPassword::verify($password, $user->password, $algo)) {
$user->password = Hash::make($password);
$user->needs_password_reset = false;
$user->legacy_password_algo = null;
$user->save();
} else {
// invalid credentials
}
}
```
> If `detectAlgo()` returns null, do NOT allow login: require password reset via email.
---
## 9) First-login Password Reset Flow (Recommended & Secure)
### Requirements
* If `needs_password_reset = 1`, user must reset password before accessing account.
* This can be enforced via middleware.
### 9.1 Middleware: `EnsurePasswordResetCompleted`
Create: `app/Http/Middleware/EnsurePasswordResetCompleted.php`
```php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsurePasswordResetCompleted
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if ($user && $user->needs_password_reset) {
if (!$request->routeIs('password.reset.*')) {
return redirect()->route('password.reset.notice');
}
}
return $next($request);
}
}
```
### 9.2 Routes for reset notice & flow
Add routes:
* `password.reset.notice` → show "You must reset your password"
* Use Laravels standard password reset email flow
### 9.3 UX recommendation
* When migrating, send optional mass email campaign:
* “SkinBase upgraded set your new password”
* But dont require sending all at once; users can request reset when needed.
---
## 10) Migration / Import Process (Recommended Order)
### Step 1: Ensure old DB is accessible
Options:
* Import old DB into same MySQL server
* Or create a read-only connection in Laravel (`config/database.php`) to `projekti_old_skinbase`
### Step 2: Run migrations for new schema
```bash
php artisan migrate
```
### Step 3: Import users
We keep `user_id` as `users.id` to preserve identity mapping.
---
## 11) Import SQL (Base migration)
> These SQL examples assume both databases are on the same MySQL server.
> Adjust database names as needed.
### 11.1 Insert into `users`
**Important rules:**
* Some users may have NULL/duplicate emails → handle safely.
* Username should be unique. If duplicates exist, add suffix.
Recommended initial import (conservative):
* Keep legacy hash in `password` temporarily
* Mark `needs_password_reset = 1`
* Set `legacy_password_algo` if detectable
```sql
INSERT INTO users (id, username, name, email, password, is_active, needs_password_reset, role, legacy_password_algo, last_visit_at, created_at, updated_at)
SELECT
u.user_id AS id,
NULLIF(u.uname, '') AS username,
NULLIF(u.real_name, '') AS name,
NULLIF(u.email, '') AS email,
COALESCE(NULLIF(u.password2, ''), NULLIF(u.password, ''), '') AS password,
CASE WHEN u.active = 1 THEN 1 ELSE 0 END AS is_active,
1 AS needs_password_reset,
'user' AS role,
NULL AS legacy_password_algo,
u.LastVisit AS last_visit_at,
u.joinDate AS created_at,
NOW() AS updated_at
FROM projekti_old_skinbase.users u;
```
> After import, you can populate `legacy_password_algo` using detection rules if you want compat.
Example:
```sql
UPDATE users
SET legacy_password_algo =
CASE
WHEN password LIKE '$2y$%' THEN 'bcrypt'
WHEN password REGEXP '^[a-f0-9]{32}$' THEN 'md5'
WHEN password REGEXP '^[a-f0-9]{40}$' THEN 'sha1'
ELSE NULL
END
WHERE legacy_password_algo IS NULL;
```
---
### 11.2 Insert into `user_profiles`
```sql
INSERT INTO user_profiles (user_id, bio, avatar, cover_image, country, country_code, language, birthdate, gender, website, created_at, updated_at)
SELECT
u.user_id,
NULLIF(u.about_me, '') AS bio,
NULLIF(u.picture, '') AS avatar,
NULLIF(u.cover_art, '') AS cover_image,
NULLIF(u.country, '') AS country,
NULLIF(LEFT(u.country_code, 2), '') AS country_code,
NULLIF(u.lang, '') AS language,
u.birth AS birthdate,
COALESCE(u.gender, 'X') AS gender,
NULLIF(u.web, '') AS website,
NOW(),
NOW()
FROM projekti_old_skinbase.users u
WHERE u.user_id IS NOT NULL;
```
---
### 11.3 Social links (only website initially)
Optionally insert website into social links (if you prefer everything in one place):
```sql
INSERT INTO user_social_links (user_id, platform, url, created_at, updated_at)
SELECT
u.user_id,
'website',
u.web,
NOW(),
NOW()
FROM projekti_old_skinbase.users u
WHERE u.web IS NOT NULL AND u.web <> '';
```
---
### 11.4 Statistics
```sql
INSERT INTO user_statistics (user_id, uploads, downloads, pageviews, awards, created_at, updated_at)
SELECT
s.user_id,
s.uploads,
s.downloads,
s.pageviews,
s.awards,
NOW(),
NOW()
FROM projekti_old_skinbase.users_statistics s;
```
---
## 12) Migration Validation SQL (Sanity Checks)
### 12.1 Count parity
```sql
SELECT
(SELECT COUNT(*) FROM projekti_old_skinbase.users) AS old_users,
(SELECT COUNT(*) FROM users) AS new_users;
```
### 12.2 Missing usernames
```sql
SELECT id, email
FROM users
WHERE username IS NULL OR username = '';
```
### 12.3 Duplicate usernames
```sql
SELECT username, COUNT(*) c
FROM users
WHERE username IS NOT NULL AND username <> ''
GROUP BY username
HAVING c > 1;
```
### 12.4 Duplicate emails
```sql
SELECT email, COUNT(*) c
FROM users
WHERE email IS NOT NULL AND email <> ''
GROUP BY email
HAVING c > 1;
```
### 12.5 Orphaned profiles
```sql
SELECT p.user_id
FROM user_profiles p
LEFT JOIN users u ON u.id = p.user_id
WHERE u.id IS NULL;
```
### 12.6 Users inactive / unauthorized review
Legacy had `authorized`. If you want to incorporate:
```sql
SELECT user_id, active, authorized
FROM projekti_old_skinbase.users
WHERE active = 0 OR authorized = 0
LIMIT 200;
```
---
## 13) Role-based Access Control (RBAC)
### Option 1 (Simple, recommended now): string role on users
* `users.role` = `user|moderator|admin`
* Add middleware checks:
* admin-only panels
* moderator actions (approve uploads, etc.)
Example middleware:
```php
public function handle($request, Closure $next, string $role)
{
$user = $request->user();
if (!$user || $user->role !== $role) abort(403);
return $next($request);
}
```
### Option 2 (Advanced later): spatie/laravel-permission
Adopt if you need granular permissions:
* `approve_artwork`
* `ban_user`
* `edit_categories`
* etc.
Not required for v1 migration.
---
## 14) Implementation Notes (Important)
### Email issues
Legacy allows NULL/duplicate emails. Laravel password reset requires unique emails.
Strategy:
* If email missing: user must login with username and request support or add email.
* If duplicate emails: resolve manually or append `+id` style (not recommended) or enforce unique by cleanup.
### Username issues
If duplicates exist, your import must resolve them.
Recommended rule:
* if username duplicate, append `-<id>`
### MyISAM note
Legacy tables are MyISAM; importing into InnoDB is fine.
Do not try to preserve MyISAM.
---
## 15) Recommended Laravel Auth Starter Kit
Use **Laravel Breeze** for simplest modern auth:
* login/register
* password resets
* email verification (optional)
Then customize:
* login field: username OR email
* middleware to enforce password reset
---
## 16) Deliverables Checklist (What Copilot must implement)
1. ✅ Migrations (sections 5.15.4)
2. ✅ Import strategy + SQL (section 11)
3. ✅ Validation SQL queries (section 12)
4. ✅ Login supports username/email (section 7)
5. ✅ Password reset enforcement (section 9)
6. ✅ Optional legacy password compatibility (section 8)
7. ✅ RBAC (section 13 option 1)
---
## 17) Final Security Policy
* Default `needs_password_reset = 1` for all migrated users.
* If legacy hash compatibility is used:
* accept legacy hash **only once**
* rehash immediately to Laravel hash
* clear legacy markers
* Do not keep MD5/SHA1 hashes long-term.
---
END OF DOCUMENT

19
.copilot/prompts.md Normal file
View File

@@ -0,0 +1,19 @@
Generate all Eloquent models for the Artworks module.
Requirements:
- Follow .copilot/artworks.md exactly
- Follow .copilot/instructions.md strictly
- Laravel 12, PHP 8.3+
- Use SoftDeletes only where allowed
- Define all relationships
- Add fillable arrays
- Add PHPDoc blocks for relations
- Do NOT add business logic
- Do NOT add observers
Models:
- Artwork
- ArtworkTranslation
- ArtworkStats
- ArtworkComment
- ArtworkDownload

View File

@@ -0,0 +1,240 @@
# SkinBase Category Link Building (AUTHORITATIVE SPEC)
This document defines the **ONLY valid way** to build public category and artwork URLs in SkinBase.
Copilot AI Agent MUST follow this specification exactly.
---
## 🎯 Goal
- SEO-friendly URLs
- No numeric IDs in public routes
- Unlimited category depth
- Predictable and deterministic link building
- One canonical URL per resource
---
## 🚫 Forbidden Concepts
Copilot MUST NOT:
- expose numeric IDs in URLs
- use legacy paths (`/Photography/3`)
- infer hierarchy from URL text
- mix `content_type_id` and `parent_id`
- create alternative URL formats
- generate uppercase URLs
---
## 🧱 Data Model (Authoritative)
### content_types
- `id`
- `slug` → FIRST URL segment
Examples:
```
photography
wallpapers
skins
other
```
---
### categories
- `id`
- `content_type_id`
- `parent_id`
- `slug`
Rules:
- `parent_id = NULL` → root category
- `parent_id != NULL` → child category
- `parent_id` MUST reference `categories.id`
- `content_type_id` MUST reference `content_types.id`
---
## 🧭 URL Structure (MANDATORY)
### Category URLs
```
/{content_type.slug}/{category-path}
```
Where:
- `category-path` is built from category slugs in hierarchy order
Examples:
```
/photography
/photography/abstract
/photography/abstract/dark
/skins/media-players
/other/art
```
---
### Artwork URLs
```
/{content_type.slug}/{category-path}/{artwork.slug}
```
Examples:
```
/photography/abstract/dark/night-city
/skins/media-players/zoom-player-dark
```
Rules:
- Artwork MUST belong to the last category in the path
- Artwork slug is ALWAYS the final segment
---
## 🧠 Category Path Construction (STRICT RULE)
Category paths MUST be constructed by walking parents.
Algorithm (conceptual):
1. Start with current category
2. Collect its `slug`
3. Move to `parent`
4. Repeat until `parent_id = NULL`
5. Reverse collected slugs
6. Join with `/`
Example:
```
Photography
└── Abstract
└── Dark
```
Produces:
```
abstract/dark
```
Final URL:
```
/photography/abstract/dark
````
---
## 🧩 Laravel Helper Contract
Category model MUST expose:
```php
$category->full_slug_path
````
Which returns:
```
abstract/dark
```
Final URL generation:
```php
'/' . $category->contentType->slug . '/' . $category->full_slug_path
```
---
## 🧭 Breadcrumb Rules
Breadcrumbs MUST reflect hierarchy exactly:
Example:
```
Home → Photography → Abstract → Dark
```
Each breadcrumb link MUST use the same slug-based URL logic.
---
## 🔐 Canonical URL RULE (SEO)
Every category and artwork page MUST include:
```html
<link rel="canonical" href="https://skinbase.org/{full-slug-url}">
```
Canonical URL MUST be:
* lowercase
* slug-based
* without IDs
* without query parameters
---
## 🧨 Legacy URL Handling
Legacy URLs MUST be handled ONLY via **301 redirects**.
Examples:
```
/Photography/3
/Photography/Business/564
```
Redirect to:
```
/photography/business
```
Copilot MUST NOT generate new legacy URLs.
---
## ✅ Validation Rules
Copilot MUST ensure:
* all URLs are lowercase
* slugs are used exclusively
* depth is unlimited
* parent relationships are respected
* only ONE URL exists per resource
---
## 🏁 FINAL STATEMENT
This document is the **single source of truth** for SkinBase category link building.
If any instruction conflicts with older code, documentation, or assumptions,
THIS DOCUMENT WINS.
END OF SPEC

309
.copilot/thumbnails.md Normal file
View File

@@ -0,0 +1,309 @@
# Skinbase Thumbnails Generation Rules
## Project
Skinbase.org Artwork / Wallpapers / Skins CDN
CDN Base URL (Public):
https://files.skinbase.org
All generated thumbnails must be publicly accessible under this domain.
---
## 1. Goals
- Generate fast-loading, high-quality thumbnails
- Optimize for CDN delivery (Cloudflare + Apache)
- Preserve visual quality
- Keep consistent sizes
- Use immutable filenames (hash-based)
---
## 2. Supported Input Formats
Source images may be:
- JPG / JPEG
- PNG
- WEBP
- TIFF
- PSD (flattened first)
Before thumbnail generation:
- Strip EXIF metadata
- Convert to RGB
- Normalize orientation
---
## 3. Output Formats
Primary output:
- WEBP (preferred)
- AVIF (optional, future use)
- JPG (fallback only if WebP fails)
Default quality:
| Format | Quality |
|--------|---------|
| WebP | 82 |
| AVIF | 45 |
| JPG | 85 |
---
## 4. Thumbnail Sizes
Generate the following sizes for every image:
| Type | Width | Height | Crop |
|------|--------|--------|------|
| xs | 150px | auto | no |
| sm | 300px | auto | no |
| md | 600px | auto | no |
| lg | 1200px | auto | no |
| sq | 400px | 400px | yes |
Rules:
- Keep aspect ratio for non-square
- Never upscale
- Use center crop for sq
---
## 5. File Naming Convention
All thumbnails must use hash-based paths.
Format:
/lg/ff/2e/ff2e9ba2277b6b8296a0011c618ebf20c8c334a2.webp
Public URL example:
https://files.skinbase.org/lg/ff/2e/ff2e9ba2277b6b8296a0011c618ebf20c8c334a2.webp
Rules:
- Hash = SHA1(original_file + size + timestamp)
- First 2 bytes = dir1
- Next 2 bytes = dir2
- Next 2 bytes = dir3
- Filename = full hash
---
## 6. Directory Structure
Base directory (server):
/opt/www/virtual/skinbase/files/
Public mapping:
/opt/www/virtual/skinbase/files/lg/...
→ https://files.skinbase.org/lg/...
Structure:
/xs/
/sm/
/md/
/lg/
/sq/
Each contains hashed subfolders.
Do not store flat files.
---
## 7. Image Processing Rules
When generating thumbnails:
1. Load source image
2. Auto-orient
3. Strip metadata
4. Resize
5. Apply mild sharpening
6. Encode WebP
7. Save to CDN path
Sharpening:
- Radius: 0.5
- Amount: 0.3
- Threshold: 0
No heavy filters allowed.
---
## 8. Background Handling
For transparent images:
- Preserve alpha channel
- Do NOT add background
- Keep transparent WebP
For JPG fallback:
- Background: #000000
---
## 9. Performance Constraints
Target limits:
| Metric | Value |
|--------|-------|
| Max size (lg) | 400 KB |
| Max size (md) | 180 KB |
| Max size (sm) | 80 KB |
| Max size (xs) | 30 KB |
| Max size (sq) | 150 KB |
If exceeded:
- Lower quality by 5
- Re-encode
---
## 10. Security Rules
- Never execute embedded scripts
- Reject SVG with scripts
- Reject malformed images
- Validate MIME type
- Validate dimensions
Max source size: 100 MB
---
## 11. Cache Compatibility
All outputs must be CDN-ready.
Headers expected:
Cache-Control: public, max-age=31536000, immutable
Never generate filenames that change.
---
## 12. Regeneration Rules
Thumbnails must be regenerated when:
- Source image changes
- Processing rules change
- Quality profile updated
Old thumbnails must remain (cache-safe).
---
## 13. Laravel Integration
When thumbnail is created:
1. Save metadata to DB
2. Store hash
3. Store size
4. Store extension
5. Store public URL
Public URL format:
https://files.skinbase.org/{size}/{dir1}/{dir2}/{hash}.webp
Where:
{size} ∈ { xs, sm, md, lg, sq }
Model fields:
- thumb_hash
- thumb_ext
- thumb_size
- thumb_width
- thumb_height
- thumb_url
---
## 14. Logging
Every generation must log:
- Source path
- Output path
- Public URL
- Size
- Time
- Result
Format: JSON
Example:
{
"source": "upload/a.jpg",
"target": "lg/ff/2e/...",
"url": "https://files.skinbase.org/lg/ff/2e/...",
"size": "lg",
"time_ms": 120,
"status": "ok"
}
---
## 15. Error Handling
If generation fails:
- Log error
- Mark record as failed
- Do not retry more than 3x
- Alert admin
---
## 16. Forbidden Actions
AI agents must NOT:
- Overwrite existing thumbnails
- Change naming rules
- Change directory layout
- Serve via PHP
- Store in public uploads
- Generate relative URLs
All URLs must be absolute and use https://files.skinbase.org
---
## 17. Future Extensions
Planned:
- AVIF support
- DPR variants (2x, 3x)
- Smart cropping
- Face detection
- AI upscaling (optional)
Do not implement without approval.
---
## End of Rules

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

73
.env.example Normal file
View File

@@ -0,0 +1,73 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=skinbase26
DB_USERNAME=root
DB_PASSWORD=
# Legacy database connection (projekti_old_skinbase)
LEGACY_DB_CONNECTION=mysql
LEGACY_DB_HOST=127.0.0.1
LEGACY_DB_PORT=3306
LEGACY_DB_DATABASE=projekti_old_skinbase
LEGACY_DB_USERNAME=root
LEGACY_DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

35
app/Banner.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App;
class Banner
{
public static function ShowResponsiveAd()
{
echo '<div class="responsive_ad">';
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
echo '</div>';
}
public static function ShowBanner300x250()
{
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:inline-block;width:300px;height:250px" data-ad-client="ca-pub-6457864535683080" data-ad-slot="7579263359"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
public static function ShowBanner728x90()
{
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:inline-block;width:728px;height:90px" data-ad-client="ca-pub-6457864535683080" data-ad-slot="1234567890"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
public static function ShowBannerGoogle300x250()
{
// alias to ShowBanner300x250 for compatibility
self::ShowBanner300x250();
}
}

100
app/Chat.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
namespace App;
use Illuminate\Support\Facades\DB;
class Chat
{
public $username = "";
public $nickname = "";
public function Authenticate()
{
}
public function StoreMessage($tekst)
{
$userId = $_SESSION['web_login']['user_id'] ?? null;
$username = $_SESSION['web_login']['username'] ?? null;
if (empty($userId) || empty($username) || empty($tekst)) {
return;
}
$last = DB::connection('legacy')->table('chat')
->select('message')
->where('user_id', $userId)
->orderByDesc('chat_id')
->limit(1)
->first();
if (!$last || ($last->message ?? '') !== $tekst) {
DB::connection('legacy')->table('chat')->insert([
'time' => now(),
'sender' => $username,
'user_id' => $userId,
'message' => $tekst,
]);
}
}
public function UpdateChatFile($chat_file, $num_rows)
{
$output = "<ul>";
$chats = DB::connection('legacy')->table('chat')
->select('time', 'sender', 'message')
->orderByDesc('chat_id')
->limit((int)$num_rows ?: 8)
->get();
$x = 0;
foreach ($chats as $chat) {
$x++;
$add = ($x % 2 === 0) ? ' class="odd" ' : '';
$datetime = date("F jS @ H:i", strtotime($chat->time));
$message = wordwrap($chat->message, 20, " ", true);
$ime = wordwrap($chat->sender, 12, " ", true);
$output .= '<li ' . $add . '>&lt;';
$output .= '<a href="/profile.php?uname=' . rawurlencode($chat->sender) . '" title="' . htmlspecialchars($datetime, ENT_QUOTES, 'UTF-8') . ' GMT+1">';
$output .= htmlspecialchars($ime, ENT_QUOTES, 'UTF-8');
$output .= '</a>&gt; ';
$output .= htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
$output .= '</li>';
}
$output .= '</ul>';
@file_put_contents(base_path($chat_file), $output);
}
public function ShowOnline()
{
echo '<div id="oboks" name="boks">Loading...</div>';
}
public function ShowChat($num_rows = 10, $username = null)
{
echo '<div id="chat_box" name="chat_box">Loading...</div>';
echo '<div class="row well">';
if (!empty($_SESSION['web_login']['status'])) {
echo '<form action="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '" method="post">';
echo '<div class="col-sm-10">';
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
echo '</div>';
echo '<div class="col-sm-2">';
echo '<button type="submit" class="btn btn-success">Say</button>';
echo '</div>';
echo '<input type="hidden" name="store_chat" value="true">';
echo '</form>';
} else {
echo '<div class="clear alert alert-danger">You should be logged in to join a chat!</div>';
}
echo '</div>';
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportArtworkHashes extends Command
{
protected $signature = 'artworks:import-hashes {file=artworks_hash_skinbase.csv} {--start=0} {--limit=0}';
protected $description = 'Import artwork hash, file_ext and thumb_ext from CSV (id,hash,file_ext,thumb_ext)';
public function handle(): int
{
$file = $this->argument('file');
$path = base_path($file);
if (!is_readable($path)) {
$this->error("CSV file not readable: {$path}");
return 1;
}
$handle = fopen($path, 'r');
if (!$handle) {
$this->error('Unable to open CSV file');
return 1;
}
// Read header
$header = fgetcsv($handle);
if ($header === false) {
$this->error('CSV appears empty');
fclose($handle);
return 1;
}
$start = (int) $this->option('start');
$limit = (int) $this->option('limit');
$rowIndex = 0;
$bar = null;
// Optionally count lines for progress bar
if ($limit === 0) {
// We'll not determine total to keep memory low
}
while (($row = fgetcsv($handle)) !== false) {
$rowIndex++;
if ($rowIndex <= $start) {
continue;
}
if ($limit > 0 && ($rowIndex - $start) > $limit) {
break;
}
// Expecting columns: id,hash,file_ext,thumb_ext
$id = isset($row[0]) ? trim($row[0], "\" ") : null;
$hash = isset($row[1]) ? trim($row[1], "\" ") : null;
$file_ext = isset($row[2]) ? trim($row[2], "\" ") : null;
$thumb_ext = isset($row[3]) ? trim($row[3], "\" ") : null;
if (empty($id)) {
continue;
}
// Normalize null strings
if (strtoupper($hash) === 'NULL') $hash = null;
if (strtoupper($file_ext) === 'NULL') $file_ext = null;
if (strtoupper($thumb_ext) === 'NULL') $thumb_ext = null;
try {
DB::table('artworks')->where('id', $id)->limit(1)->update([
'hash' => $hash,
'file_ext' => $file_ext,
'thumb_ext' => $thumb_ext,
]);
} catch (\Throwable $e) {
// Log and continue on duplicate / other DB errors
$this->error("Row {$rowIndex} (id={$id}) failed: " . $e->getMessage());
continue;
}
if ($rowIndex % 500 === 0) {
$this->info("Processed {$rowIndex} rows");
}
}
fclose($handle);
$this->info('Import complete.');
return 0;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
class ImportCategories extends Command
{
protected $signature = 'import:categories {path=database/artworks_categories.csv}';
protected $description = 'Import categories from legacy CSV into categories table';
public function handle(): int
{
$path = base_path($this->argument('path'));
if (!file_exists($path)) {
$this->error("CSV file not found: {$path}");
return 1;
}
$file = new \SplFileObject($path);
$file->setFlags(\SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY);
$header = null;
$rows = [];
foreach ($file as $index => $row) {
if ($row === [null] || $row === false) {
continue;
}
if ($index === 0) {
$header = $row;
continue;
}
// normalize row length
if (!is_array($row)) {
continue;
}
$rows[] = $row;
}
// build a map of rows by category id to allow ancestor lookups
$map = [];
foreach ($rows as $r) {
$rid = isset($r[0]) ? (int)$r[0] : null;
if ($rid) {
$map[$rid] = $r;
}
}
// Temporarily disable foreign key checks to allow inserting parents out of order
DB::statement('SET FOREIGN_KEY_CHECKS=0');
// truncate categories table to start fresh
DB::table('categories')->truncate();
DB::beginTransaction();
try {
$this->info('Inserting categories...');
// helper to walk ancestor chain and find a valid content_type_id
$findAncestorContentType = function ($startRoot) use ($map) {
$seen = [];
$cur = $startRoot;
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
$seen[] = $cur;
$ancestor = $map[$cur];
$candidate = isset($ancestor[7]) ? (int)$ancestor[7] : null;
if (!empty($candidate)) {
// verify the content type exists in DB
if (DB::table('content_types')->where('id', $candidate)->exists()) {
return $candidate;
}
}
$cur = isset($ancestor[8]) ? (int)$ancestor[8] : null;
}
return null;
};
// helper to collect ancestor chain rows for debugging dump
$collectAncestorChain = function ($startRoot) use ($map) {
$chain = [];
$seen = [];
$cur = $startRoot;
while ($cur && isset($map[$cur]) && !in_array($cur, $seen, true)) {
$seen[] = $cur;
$r = $map[$cur];
$chain[] = [
'id' => $cur,
'name' => $r[1] ?? null,
'section_id' => isset($r[7]) ? (int)$r[7] : null,
'rootid' => isset($r[8]) ? (int)$r[8] : null,
'raw' => $r,
];
$cur = isset($r[8]) ? (int)$r[8] : null;
}
return $chain;
};
foreach ($rows as $row) {
// CSV format: category_id,category_name,num_artworks,description,official_webpage,picture,views,section_id,rootid
$origId = isset($row[0]) ? (int)$row[0] : null;
$name = isset($row[1]) ? trim($row[1]) : '';
$description = isset($row[3]) && $row[3] !== '' ? $row[3] : null;
$image = isset($row[5]) && $row[5] !== '' ? $row[5] : null;
$contentTypeId = isset($row[7]) ? (int)$row[7] : null;
$rootId = isset($row[8]) ? (int)$row[8] : null;
// If the CSV `rootid` corresponds to a content type, it's a root for that content type => parent_id NULL
if (!empty($rootId) && DB::table('content_types')->where('id', $rootId)->exists()) {
$parentId = null;
} else {
// If content_type_id == parent_id then parent_id should be NULL
$parentId = ($contentTypeId === $rootId || $rootId === 0) ? null : $rootId;
}
// resolve missing/invalid content_type_id from ancestor chain (parent/root)
$ctExists = !empty($contentTypeId) && DB::table('content_types')->where('id', $contentTypeId)->exists();
if (!$ctExists) {
if (!empty($rootId)) {
$resolved = $findAncestorContentType($rootId);
if ($resolved) {
$contentTypeId = $resolved;
$this->info("Resolved content_type_id for category {$origId} from ancestor: {$contentTypeId}");
}
}
}
// after resolution, verify content_type exists
if (empty($contentTypeId) || !DB::table('content_types')->where('id', $contentTypeId)->exists()) {
$this->error("Missing content_type_id for category {$origId}. Dumping data and aborting import.");
$dump = [
'row' => $row,
'resolved_root' => $rootId,
'ancestor_chain' => $rootId ? $collectAncestorChain($rootId) : [],
];
$this->line(json_encode($dump, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
throw new \RuntimeException('Aborting import: unresolved content_type_id for category ' . $origId);
}
$slug = Str::slug($name) ?: 'cat-' . $origId;
// ensure uniqueness for slug within content_type
$existing = DB::table('categories')
->where('content_type_id', $contentTypeId)
->where('slug', $slug)
->exists();
if ($existing) {
$slug = $slug . '-' . $origId;
}
$now = now();
$data = [
'id' => $origId,
'content_type_id' => $contentTypeId,
'parent_id' => $parentId,
'name' => $name,
'slug' => $slug,
'description' => $description,
'image' => $image,
'is_active' => true,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
];
DB::table('categories')->insert($data);
$this->line("Inserted category {$origId}: {$name}");
}
DB::commit();
$this->info('Import completed.');
return 0;
} catch (\Throwable $e) {
DB::rollBack();
$this->error('Import failed: ' . $e->getMessage());
return 1;
} finally {
// re-enable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
}
}

View File

@@ -0,0 +1,291 @@
<?php
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/**
* Import artworks from legacy `wallz` table and attach categories via `connected` table.
*
* Usage:
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
*/
class ImportLegacyArtworks extends Command
{
protected $signature = 'skinbase:import-legacy-artworks
{--chunk=500 : chunk size for processing}
{--limit= : maximum number of legacy rows to import}
{--dry-run : do not persist any changes}
{--legacy-connection=legacy : name of legacy DB connection}
{--legacy-table=wallz : legacy artworks table name}
{--connected-table=connected : legacy artwork->category table}
';
protected $description = 'Import artworks from legacy DB (wallz) into new artworks table';
private function coerceUnsignedInt(mixed $value, int $default = 0): int
{
if ($value === null) {
return $default;
}
if (is_bool($value)) {
return $value ? 1 : 0;
}
if (is_int($value)) {
return max(0, $value);
}
if (is_float($value)) {
return max(0, (int) $value);
}
if (is_string($value)) {
$trimmed = trim($value);
if ($trimmed === '') {
return $default;
}
if (is_numeric($trimmed)) {
return max(0, (int) $trimmed);
}
}
return $default;
}
private function coerceString(mixed $value, string $default = ''): string
{
if ($value === null) {
return $default;
}
$stringValue = trim((string) $value);
return $stringValue !== '' ? $stringValue : $default;
}
public function handle(): int
{
$chunk = (int) $this->option('chunk');
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
$dryRun = (bool) $this->option('dry-run');
$legacyConn = $this->option('legacy-connection');
$legacyTable = $this->option('legacy-table');
$connectedTable = $this->option('connected-table');
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
$processed = 0;
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
foreach ($rows as $row) {
if ($limit !== null && $processed >= $limit) {
return false; // stop chunking
}
$legacyId = $row->id ?? null;
$title = $row->name ?? $row->title ?? ($row->headline ?? ('legacy-' . ($legacyId ?? Str::random(6))));
$description = $row->description ?? $row->desc ?? null;
$slugBase = Str::slug(substr((string) $title, 0, 120));
// Use cleaned title slug directly. If no title, fallback to artwork-<id|random>.
$slug = $slugBase ? $slugBase : 'artwork-' . ($legacyId ?? Str::random(8));
$publishedAt = null;
if (! empty($row->datum)) {
$publishedAt = date('Y-m-d H:i:s', strtotime($row->datum));
} elseif (! empty($row->created_at)) {
$publishedAt = $row->created_at;
}
// File mapping — try common legacy fields. Normalize and ensure file_path is not null.
$rawFileName = $row->pic ?? $row->picture ?? $row->file ?? $row->fname ?? null;
$fileName = null;
$filePath = '';
if (! empty($rawFileName) && trim((string) $rawFileName) !== '') {
$fileName = trim((string) $rawFileName);
// store legacy path under legacy/ folder, but do not move files here — admin can handle file migration
$filePath = 'legacy/uploads/' . ltrim($fileName, '/');
}
// derive mime type if missing (use extension mapping), fallback to application/octet-stream
$mime = $row->mimetype ?? $row->mime ?? null;
if (empty($mime) && $fileName) {
$ext = strtolower(pathinfo((string) $fileName, PATHINFO_EXTENSION));
$map = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'ico' => 'image/x-icon',
'zip' => 'application/zip',
'pdf' => 'application/pdf',
];
$mime = $map[$ext] ?? null;
}
if (empty($mime)) {
$mime = 'application/octet-stream';
}
$data = [
'id' => $row->id ?? null,
// NOTE: artworks.user_id is NOT NULL (no FK constraint, but column cannot be null)
'user_id' => $row->user_id ?? 1,
'title' => (string) $title,
'slug' => (string) $slug,
'description' => $description,
'file_name' => $fileName,
// ensure non-null file_path to satisfy NOT NULL DB constraints
'file_path' => $filePath ?? '',
// legacy DB sometimes has no filesize; default to 0 to satisfy NOT NULL
'file_size' => isset($row->filesize) && $row->filesize !== null ? (int) $row->filesize : (isset($row->size) && $row->size !== null ? (int) $row->size : 0),
'mime_type' => $mime,
'width' => $row->width ?? null,
'height' => $row->height ?? null,
'is_public' => isset($row->visible) ? (bool) $row->visible : true,
'is_approved' => isset($row->approved) ? (bool) $row->approved : true,
'published_at' => $publishedAt,
];
// Coerce required NOT NULL columns to safe defaults (legacy data can be messy)
$data['user_id'] = $this->coerceUnsignedInt($data['user_id'], 1);
$data['file_name'] = $this->coerceString($data['file_name'], 'legacy-' . ($legacyId ?? Str::random(8)));
$data['file_path'] = $this->coerceString($data['file_path'], 'legacy/uploads/' . $data['file_name']);
$data['mime_type'] = $this->coerceString($data['mime_type'], 'application/octet-stream');
$data['file_size'] = $this->coerceUnsignedInt($data['file_size'], 0);
$data['width'] = $this->coerceUnsignedInt($data['width'], 0);
$data['height'] = $this->coerceUnsignedInt($data['height'], 0);
$this->line('Importing legacy id=' . ($legacyId ?? 'unknown') . ' title=' . $data['title']);
if ($dryRun) {
$processed++;
continue;
}
try {
$art = null;
DB::connection()->transaction(function () use (&$art, $data, $legacyId, $legacyConn, $connectedTable) {
// create artwork (guard against unique slug collisions)
$baseSlug = $data['slug'];
$attempt = 0;
$slug = $baseSlug;
while (Artwork::where('slug', $slug)->exists()) {
$attempt++;
$slug = $baseSlug . '-' . $attempt;
}
$data['slug'] = $slug;
// Preserve legacy primary ID if available and safe to do so.
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
$preserveId = (int) $legacyId;
if (Artwork::where('id', $preserveId)->exists()) {
// Avoid overwriting an existing artwork with the same id.
throw new \RuntimeException("Artwork with id {$preserveId} already exists; skipping import for this legacy id.");
}
$data['id'] = $preserveId;
}
// If we need to preserve the legacy primary id, perform a raw insert
// so auto-increment doesn't assign a different id. Otherwise use Eloquent.
if (! empty($data['id'])) {
$insert = $data;
$ts = date('Y-m-d H:i:s');
if (! array_key_exists('created_at', $insert)) {
$insert['created_at'] = $ts;
}
if (! array_key_exists('updated_at', $insert)) {
$insert['updated_at'] = $ts;
}
DB::table('artworks')->insert($insert);
$art = Artwork::find($insert['id']);
} else {
$art = Artwork::create($data);
}
// attach categories if connected table exists
if (DB::connection($legacyConn)->getSchemaBuilder()->hasTable($connectedTable)) {
// attempt to find category ids from connected table; common column names: wallz_id, art_id, connected_id
$rows = DB::connection($legacyConn)->table($connectedTable)
->where(function ($q) use ($legacyId) {
$q->where('wallz_id', $legacyId)
->orWhere('art_id', $legacyId)
->orWhere('item_id', $legacyId);
})->get();
$categoryIds = [];
foreach ($rows as $r) {
$cid = $r->category_id ?? $r->cat_id ?? $r->category ?? null;
if ($cid) {
// try to find matching Category in new DB by id or slug
if (is_numeric($cid) && \App\Models\Category::where('id', $cid)->exists()) {
$categoryIds[] = (int) $cid;
} else {
// maybe legacy stores slug
$cat = \App\Models\Category::where('slug', $cid)->first();
if ($cat) {
$categoryIds[] = $cat->id;
}
}
}
}
if (! empty($categoryIds)) {
$art->categories()->syncWithoutDetaching(array_values(array_unique($categoryIds)));
}
}
});
// Post-insert verification: if we attempted to preserve the legacy id,
// confirm the row exists with that id. Log mapping if preservation failed.
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {
$preserveId = (int) $legacyId;
$exists = Artwork::where('id', $preserveId)->exists();
if (! $exists) {
// If $art was created but with a different id, log mapping for manual reconciliation
if ($art instanceof Artwork) {
Log::warning('Imported legacy artwork but failed to preserve id', [
'legacy_id' => $preserveId,
'created_id' => $art->id,
'slug' => $art->slug ?? null,
]);
} else {
Log::warning('Legacy artwork not found after import', ['legacy_id' => $preserveId]);
}
}
}
$processed++;
} catch (Throwable $e) {
$this->error('Failed to import legacy id=' . ($legacyId ?? 'unknown') . ': ' . $e->getMessage());
Log::error('ImportLegacyArtworks error', [
'legacy_id' => $legacyId,
'error' => $e->getMessage(),
'data' => $data ?? null,
'trace' => $e->getTraceAsString(),
]);
}
}
return null;
}, 'id');
$this->info('Import complete. Processed: ' . $processed);
return 0;
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ImportLegacyUsers extends Command
{
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users}';
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
protected array $usedUsernames = [];
protected array $usedEmails = [];
public function handle(): int
{
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
$chunk = (int) $this->option('chunk');
$imported = 0;
$skipped = 0;
if (! DB::connection('legacy')->getPdo()) {
$this->error('Legacy DB connection "legacy" is not configured or reachable.');
return self::FAILURE;
}
DB::connection('legacy')
->table('users')
->chunkById($chunk, function ($rows) use (&$imported, &$skipped) {
$ids = $rows->pluck('user_id')->all();
$stats = DB::connection('legacy')
->table('users_statistics')
->whereIn('user_id', $ids)
->get()
->keyBy('user_id');
foreach ($rows as $row) {
try {
$this->importRow($row, $stats[$row->user_id] ?? null);
$imported++;
} catch (\Throwable $e) {
$skipped++;
$this->warn("Skip user_id {$row->user_id}: {$e->getMessage()}");
}
}
}, 'user_id');
$this->info("Imported: {$imported}, Skipped: {$skipped}");
return self::SUCCESS;
}
protected function importRow($row, $statRow = null): void
{
$legacyId = (int) $row->user_id;
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
$username = $this->uniqueUsername($baseUsername);
$email = $this->prepareEmail($row->email ?? null, $username);
$legacyPassword = $row->password2 ?: $row->password ?: null;
// Optionally force-reset every imported user's password to a secure random value.
if ($this->option('force-reset-all')) {
$this->warn("Force-reset-all enabled: generating secure password for user_id {$row->user_id}.");
$passwordHash = Hash::make(Str::random(64));
} else {
// Force-reset known weak default passwords (e.g. "abc123").
if ($legacyPassword !== null && trim($legacyPassword) === 'abc123') {
$this->warn("Weak password 'abc123' detected for user_id {$row->user_id}; forcing reset.");
$passwordHash = Hash::make(Str::random(64));
} else {
$passwordHash = Hash::make($legacyPassword ?: Str::random(32));
}
}
$uploads = $this->sanitizeStatValue($statRow->uploads ?? 0);
$downloads = $this->sanitizeStatValue($statRow->downloads ?? 0);
$pageviews = $this->sanitizeStatValue($statRow->pageviews ?? 0);
$awards = $this->sanitizeStatValue($statRow->awards ?? 0);
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
$now = now();
DB::table('users')->insert([
'id' => $legacyId,
'username' => $username,
'name' => $row->real_name ?: $username,
'email' => $email,
'password' => $passwordHash,
'is_active' => (int) ($row->active ?? 1) === 1,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'last_visit_at' => $row->LastVisit ?: null,
'created_at' => $row->joinDate ?: $now,
'updated_at' => $now,
]);
DB::table('user_profiles')->insert([
'user_id' => $legacyId,
'bio' => $row->about_me ?: $row->description ?: null,
'avatar' => $row->picture ?: null,
'cover_image' => $row->cover_art ?: null,
'country' => $row->country ?: null,
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
'language' => $row->lang ?: null,
'birthdate' => $row->birth ?: null,
'gender' => $row->gender ?: 'X',
'website' => $row->web ?: null,
'created_at' => $now,
'updated_at' => $now,
]);
if (!empty($row->web)) {
DB::table('user_social_links')->insert([
'user_id' => $legacyId,
'platform' => 'website',
'url' => $row->web,
'created_at' => $now,
'updated_at' => $now,
]);
}
DB::table('user_statistics')->insert([
'user_id' => $legacyId,
'uploads' => $uploads,
'downloads' => $downloads,
'pageviews' => $pageviews,
'awards' => $awards,
'created_at' => $now,
'updated_at' => $now,
]);
});
}
/**
* Ensure statistic values are safe for unsigned DB columns.
*/
protected function sanitizeStatValue($value): int
{
$n = is_numeric($value) ? (int) $value : 0;
if ($n < 0) {
return 0;
}
return $n;
}
protected function sanitizeUsername(string $username): string
{
$username = strtolower(trim($username));
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
return trim($username, '.-') ?: 'user';
}
protected function uniqueUsername(string $base): string
{
$name = $base;
$i = 1;
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
$name = $base . '-' . $i;
$i++;
}
$this->usedUsernames[$name] = $name;
return $name;
}
protected function prepareEmail(?string $legacyEmail, string $username): string
{
$legacyEmail = $legacyEmail ? strtolower(trim($legacyEmail)) : null;
$baseLocal = $this->sanitizeEmailLocal($username);
$domain = 'users.skinbase.org';
$email = $legacyEmail ?: ($baseLocal . '@' . $domain);
$email = $this->uniqueEmail($email, $baseLocal, $domain);
return $email;
}
protected function uniqueEmail(string $email, string $baseLocal, string $domain): string
{
$i = 1;
$local = explode('@', $email)[0];
$current = $email;
while (isset($this->usedEmails[$current]) || DB::table('users')->where('email', $current)->exists()) {
$current = $local . $i . '@' . $domain;
$i++;
}
$this->usedEmails[$current] = $current;
return $current;
}
protected function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class MigrateFeaturedWorks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'migrate:featured-works {--dry-run : Do not write any rows} {--limit=0 : Stop after inserting this many rows} {--legacy-connection=legacy : name of legacy DB connection} {--legacy-table=featured_works : legacy table name} {--start-id=0 : Start processing from this legacy featured_id} {--chunk=500 : Chunk size for processing}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate rows from legacy featured_works into artwork_features safely';
public function handle()
{
$dryRun = $this->option('dry-run');
$limit = (int) $this->option('limit');
$this->info('Starting migration from `featured_works` to `artwork_features`');
if ($dryRun) {
$this->info('Running in dry-run mode; no inserts will be performed.');
}
$inserted = 0;
$skipped = 0;
$total = 0;
$startId = (int) $this->option('start-id');
$chunk = (int) $this->option('chunk');
$mapping = [
3 => 10, // Gold -> high priority
2 => 20, // Silver
1 => 30, // Bronze
4 => 50, // Featured
0 => 100 // default
];
$legacyConn = $this->option('legacy-connection');
$legacyTable = $this->option('legacy-table');
$this->info("Reading from legacy connection '{$legacyConn}' table '{$legacyTable}'");
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('featured_id');
if ($startId > 0) {
$this->info("Resuming from featured_id >= {$startId}");
$query = $query->where('featured_id', '>=', $startId);
}
$query->chunkById($chunk, function ($rows) use (&$inserted, &$skipped, &$total, $dryRun, $limit, $mapping) {
foreach ($rows as $row) {
$total++;
if ($limit > 0 && $inserted >= $limit) {
return false; // stop chunking
}
$artworkId = isset($row->artwork_id) ? (int) $row->artwork_id : 0;
if ($artworkId <= 0) {
$skipped++;
continue;
}
// Verify artwork exists
$exists = DB::table('artworks')->where('id', $artworkId)->exists();
if (! $exists) {
$skipped++;
continue;
}
// Avoid duplicate active feature for same artwork
$already = DB::table('artwork_features')
->where('artwork_id', $artworkId)
->where('is_active', true)
->exists();
if ($already) {
$skipped++;
continue;
}
// Determine featured_at
$postDate = $row->post_date ?? null;
if (empty($postDate) || $postDate === '0000-00-00' || $postDate === '0000-00-00 00:00:00') {
$featuredAt = Carbon::now();
} else {
try {
$featuredAt = Carbon::parse($postDate);
} catch (\Throwable $e) {
$featuredAt = Carbon::now();
}
}
// Map priority from legacy 'type'
$type = isset($row->type) ? (int) $row->type : 0;
$priority = $mapping[$type] ?? 100;
// Validate created_by: only set if a valid user id exists in new users table
$createdBy = isset($row->user_id) ? (int) $row->user_id : null;
if ($createdBy <= 0 || ! DB::table('users')->where('id', $createdBy)->exists()) {
$createdBy = null;
}
$record = [
'artwork_id' => $artworkId,
'featured_at' => $featuredAt->toDateTimeString(),
'expires_at' => null,
'priority' => $priority,
'label' => null,
'note' => $row->description ?? null,
'is_active' => 1,
'created_by' => $createdBy,
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
];
if ($dryRun) {
$this->line('[dry] Insert: artwork_id=' . $artworkId . ' featured_at=' . $record['featured_at'] . ' priority=' . $priority);
$inserted++;
continue;
}
try {
DB::table('artwork_features')->insert($record);
$inserted++;
} catch (\Throwable $e) {
$this->error('Failed to insert artwork_id=' . $artworkId . ' : ' . $e->getMessage());
$skipped++;
}
}
// Return true to continue, false to stop chunking
return ($limit > 0 && $inserted >= $limit) ? false : true;
}, 'featured_id');
$this->info("Done. Processed: {$total}, Inserted: {$inserted}, Skipped: {$skipped}");
return 0;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class ResetAllUserPasswords extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'skinbase:reset-all-passwords {--chunk=500 : Chunk size for processing} {--yes : Skip confirmation}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reset all user passwords to secure random values and flag for password reset.';
public function handle(): int
{
if (! $this->option('yes') && ! $this->confirm('This will replace every user password with a secure random value and mark accounts as needing a reset. Continue?')) {
$this->info('Aborted. No changes were made.');
return 0;
}
$chunk = (int) $this->option('chunk');
$count = DB::table('users')->count();
if ($count === 0) {
$this->info('No users found.');
return 0;
}
$bar = $this->output->createProgressBar($count);
$bar->start();
DB::table('users')->orderBy('id')->chunkById($chunk, function ($rows) use ($bar) {
foreach ($rows as $row) {
DB::table('users')->where('id', $row->id)->update([
'password' => Hash::make(Str::random(64)),
'needs_password_reset' => true,
'updated_at' => now(),
]);
$bar->advance();
}
}, 'id');
$bar->finish();
$this->newLine(2);
$this->info('All user passwords reset and accounts flagged for password reset.');
return 0;
}
}

41
app/Console/Kernel.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Console;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use App\Console\Commands\ImportLegacyUsers;
use App\Console\Commands\ImportCategories;
use App\Console\Commands\MigrateFeaturedWorks;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array<int, class-string>
*/
protected $commands = [
ImportLegacyUsers::class,
ImportCategories::class,
MigrateFeaturedWorks::class,
\App\Console\Commands\ResetAllUserPasswords::class,
];
/**
* Define the application's command schedule.
*/
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}
}

78
app/Helpers/Thumb.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Facades\Storage;
use App\Services\ThumbnailService;
class Thumb
{
/**
* Return a thumbnail URL.
*
* Usage:
* - `Thumb::url($filePath)` - fallback mapping by filename or Storage::url
* - `Thumb::url($filePath, $id, $ext)` - resolve hash-based CDN URL when possible
*
* @param string|null $filePath
* @param int|null $id
* @param string|null $ext
* @param int $size // legacy size code: 4 -> small(320), 6 -> medium(600)
* @return string
*/
public static function url(?string $filePath, ?int $id = null, ?string $ext = null, int $size = 6): string
{
return ThumbnailService::url($filePath, $id, $ext, $size);
}
/**
* Build a new-style thumbnail URL using hash and extension.
* Example: http://files.skinbase.org/md/43/f8/43f87a...360.webp
*
* @param string|null $hash
* @param string|null $ext
* @param string $sizeKey One of sm, md, lg, xl
* @return string|null
*/
public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string
{
return ThumbnailService::fromHash($hash, $ext, $sizeKey);
}
/**
* Build a simple srcset for responsive thumbnails.
* Uses sm (320w) and md (600w) by default to match legacy sizes.
*
* @param string|null $hash
* @param string|null $ext
* @return string|null
*/
public static function srcsetFromHash(?string $hash, ?string $ext): ?string
{
return ThumbnailService::srcsetFromHash($hash, $ext);
}
/**
* Return encoded id string using legacy algorithm or fallback base62.
*/
public static function encodeId(int $id): string
{
if (class_exists('\App\Services\LegacyService') && method_exists('\App\Services\LegacyService', 'encode')) {
return \App\Services\LegacyService::encode($id);
}
return self::base62encode($id);
}
private static function base62encode(int $val, int $base = 62, string $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'): string
{
$str = '';
if ($val < 0) return $str;
do {
$i = $val % $base;
$str = $chars[$i] . $str;
$val = intdiv($val - $i, $base);
} while ($val > 0);
return $str;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Http\Resources\ArtworkResource;
use App\Services\ArtworkService;
use App\Models\Category;
use Illuminate\Http\Request;
class ArtworkController extends Controller
{
protected ArtworkService $service;
public function __construct(ArtworkService $service)
{
$this->service = $service;
}
/**
* GET /api/v1/artworks/{slug}
* Returns a single public artwork resource by slug.
*/
public function show(string $slug)
{
$artwork = $this->service->getPublicArtworkBySlug($slug);
// Return the artwork instance (service already loads lightweight relations).
// Log resolved resource for debugging failing test assertions.
// Return the resolved payload directly to avoid JsonResource wrapping inconsistencies
return response()->json((new ArtworkResource($artwork))->resolve(), 200);
}
/**
* GET /api/v1/categories/{slug}/artworks
* Uses route-model binding for Category (slug). Returns paginated list resource.
*/
public function categoryArtworks(Request $request, Category $category)
{
$perPage = (int) $request->get('per_page', 24);
$paginator = $this->service->getCategoryArtworks($category, $perPage);
return ArtworkListResource::collection($paginator);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class BrowseController extends Controller
{
protected ArtworkService $service;
public function __construct(ArtworkService $service)
{
$this->service = $service;
}
/**
* GET /api/v1/browse
* Public browse feed powered by authoritative artworks table.
*/
public function index(Request $request)
{
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
$paginator = $this->service->browsePublicArtworks($perPage);
return ArtworkListResource::collection($paginator);
}
/**
* GET /api/v1/browse/{content_type}
* Browse by content type slug.
*/
public function byContentType(Request $request, string $contentTypeSlug)
{
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
try {
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage);
} catch (ModelNotFoundException $e) {
abort(404);
}
if ($paginator->count() === 0) {
return response()->json(['message' => 'Gone'], 410);
}
return ArtworkListResource::collection($paginator);
}
/**
* GET /api/v1/browse/{content_type}/{category_path}
* Browse by content type + category path (slug segments).
*/
public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath)
{
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
$slugs = array_merge([
strtolower($contentTypeSlug),
], array_values(array_filter(explode('/', trim($categoryPath, '/')))));
try {
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage);
} catch (ModelNotFoundException $e) {
abort(404);
}
if ($paginator->count() === 0) {
return response()->json(['message' => 'Gone'], 410);
}
return ArtworkListResource::collection($paginator);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork;
use App\Models\Category;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ArtworkController extends Controller
{
/**
* Browse artworks with optional category filtering.
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
*/
public function index(ArtworkIndexRequest $request, ?Category $category = null): View
{
$perPage = (int) ($request->get('per_page', 24));
$query = Artwork::public()->published();
if ($category) {
$query->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id);
});
}
if ($request->filled('q')) {
$q = $request->get('q');
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', '%' . $q . '%')
->orWhere('description', 'like', '%' . $q . '%');
});
}
$sort = $request->get('sort', 'latest');
if ($sort === 'oldest') {
$query->orderBy('published_at', 'asc');
} else {
$query->orderByDesc('published_at');
}
// Important: do NOT eager-load artwork_stats in listings
$artworks = $query->cursorPaginate($perPage);
return view('artworks.index', [
'artworks' => $artworks,
'category' => $category,
]);
}
/**
* Show a single artwork by slug. Ensure it's public, approved and not deleted.
*/
public function show(Artwork $artwork): View
{
if (! $artwork->is_public || ! $artwork->is_approved || $artwork->trashed()) {
abort(404);
}
return view('artworks.show', ['artwork' => $artwork]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BrowseCategoriesController extends Controller
{
public function index(Request $request)
{
// Use Eloquent models for canonical category URLs and grouping
$contentTypes = \App\Models\ContentType::with(['rootCategories.children'])->orderBy('id')->get();
// Prepare categories grouped by content type and a flat list of root categories
$categoriesByType = [];
$categories = collect();
foreach ($contentTypes as $ct) {
$rootCats = $ct->rootCategories;
foreach ($rootCats as $cat) {
// Attach subcategories
$cat->subcategories = $cat->children;
$categories->push($cat);
}
$categoriesByType[$ct->slug] = $rootCats;
}
return view('browse-categories', [
'contentTypes' => $contentTypes,
'categoriesByType' => $categoriesByType,
'categories' => $categories,
]);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\ContentType;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
class CategoryPageController extends Controller
{
public function show(Request $request, string $contentTypeSlug, string $categoryPath = null)
{
$contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first();
if (! $contentType) {
abort(404);
}
if ($categoryPath === null || $categoryPath === '') {
// No category path: show content-type landing page (e.g., /wallpapers)
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$page_title = $contentType->name;
$page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase');
return view('legacy.content-type', compact(
'contentType',
'rootCategories',
'page_title',
'page_meta_description'
));
}
$segments = array_filter(explode('/', $categoryPath));
if (empty($segments)) {
return redirect('/browse-categories');
}
// Traverse categories by slug path within the content type
$current = Category::where('content_type_id', $contentType->id)
->whereNull('parent_id')
->where('slug', strtolower(array_shift($segments)))
->first();
if (! $current) {
abort(404);
}
foreach ($segments as $slug) {
$current = $current->children()->where('slug', strtolower($slug))->first();
if (! $current) {
abort(404);
}
}
$category = $current;
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
// Placeholder artworks paginator (until artwork data is wired).
$page = max(1, (int) $request->query('page', 1));
$artworks = new LengthAwarePaginator([], 0, 40, $page, [
'path' => $request->url(),
'query' => $request->query(),
]);
$page_title = $category->name;
$page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase');
$page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
return view('legacy.category-slug', compact(
'contentType',
'category',
'subcategories',
'rootCategories',
'artworks',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
class ArtController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function show(Request $request, $id, $slug = null)
{
// handle comment POST from legacy form
if ($request->isMethod('post') && $request->input('action') === 'store_comment') {
if (auth()->check()) {
try {
\Illuminate\Support\Facades\DB::connection('legacy')->table('artworks_comments')->insert([
'artwork_id' => (int)$id,
'owner_user_id' => (int)($request->user()->id ?? 0),
'user_id' => (int)$request->user()->id,
'date' => now()->toDateString(),
'time' => now()->toTimeString(),
'description' => (string)$request->input('comment_text'),
]);
} catch (\Throwable $e) {
// ignore DB errors for now
}
}
return redirect()->back();
}
$data = $this->legacy->getArtwork((int) $id);
if (! $data || empty($data['artwork'])) {
return view('legacy.placeholder', ['title' => 'Artwork Not Found']);
}
// load comments for artwork (legacy schema)
try {
$comments = \Illuminate\Support\Facades\DB::connection('legacy')->table('artworks_comments as t1')
->rightJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->select('t1.description', 't1.date', 't1.time', 't2.uname', 't2.signature', 't2.icon', 't2.user_id')
->where('t1.artwork_id', (int)$id)
->where('t1.user_id', '>', 0)
->orderBy('t1.comment_id')
->get();
} catch (\Throwable $e) {
$comments = collect();
}
$data['comments'] = $comments;
return view('legacy.art', $data);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class AvatarController extends Controller
{
public function show(Request $request, $id, $name = null)
{
$user_id = (int) $id;
// default avatar in project public gfx
$defaultAvatar = public_path('gfx/avatar.jpg');
try {
$icon = DB::connection('legacy')->table('users')->where('user_id', $user_id)->value('icon');
} catch (\Throwable $e) {
$icon = null;
}
$candidates = [];
if (!empty($icon)) {
// common legacy locations to check
$candidates[] = base_path('oldSite/www/files/usericons/' . $icon);
$candidates[] = base_path('oldSite/www/files/usericons/' . rawurlencode($icon));
$candidates[] = base_path('oldSite/www/files/usericons/' . basename($icon));
$candidates[] = public_path('avatar/' . $user_id . '/' . $icon);
$candidates[] = public_path('avatar/' . $user_id . '/' . basename($icon));
$candidates[] = storage_path('app/public/usericons/' . $icon);
$candidates[] = storage_path('app/public/usericons/' . basename($icon));
}
// find first readable file
$found = null;
foreach ($candidates as $path) {
if ($path && file_exists($path) && is_readable($path)) {
$found = $path;
break;
}
}
if ($found) {
$type = @exif_imagetype($found);
if ($type) {
$mime = image_type_to_mime_type($type);
} else {
$f = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($f, $found) ?: 'application/octet-stream';
finfo_close($f);
}
return response()->file($found, ['Content-Type' => $mime]);
}
// fallback to default
if (file_exists($defaultAvatar) && is_readable($defaultAvatar)) {
return response()->file($defaultAvatar, ['Content-Type' => 'image/jpeg']);
}
// final fallback: 404
abort(404);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class BrowseController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
$page_title = 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase';
$page_meta_description = "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.";
$page_meta_keywords = 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo';
$perPage = (int) $request->get('per_page', 24);
$categoryPath = trim((string) $request->query('category', ''), '/');
try {
if ($categoryPath !== '') {
$slugs = array_values(array_filter(explode('/', $categoryPath)));
/** @var CursorPaginator $artworks */
$artworks = $this->artworks->getArtworksByCategoryPath($slugs, $perPage);
} else {
/** @var CursorPaginator $artworks */
$artworks = $this->artworks->browsePublicArtworks($perPage);
}
} catch (ModelNotFoundException $e) {
abort(404);
}
if (count($artworks) === 0) {
Log::warning('browse.missing_artworks', [
'url' => $request->fullUrl(),
'category_path' => $categoryPath ?: null,
]);
abort(410);
}
// Shape data for the legacy Blade while using authoritative tables only.
$artworks->getCollection()->transform(fn (Artwork $artwork) => $this->mapArtwork($artwork));
return view('legacy.browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'artworks'));
}
private function mapArtwork(Artwork $artwork): object
{
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categoryPath = $primaryCategory?->full_slug_path;
$contentTypeSlug = $primaryCategory?->contentType?->slug;
$webUrl = $contentTypeSlug && $categoryPath
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $artwork->slug
: null;
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
return (object) [
'id' => $artwork->id,
// Include ordering parameter used by cursor paginator so links can be generated
'published_at' => $artwork->published_at?->toAtomString(),
'slug' => $artwork->slug,
'name' => $artwork->title,
'category_name' => $primaryCategory->name ?? '',
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user->name ?? 'Skinbase',
'url' => $webUrl ?? '#',
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
use App\Models\Category;
use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class CategoryController extends Controller
{
protected ArtworkService $artworkService;
public function __construct(ArtworkService $artworkService)
{
$this->artworkService = $artworkService;
}
public function show(Request $request, $id, $slug = null, $group = null)
{
// Parse request path after '/category' to support unlimited depth and legacy routes
$path = trim($request->path(), '/');
$segments = array_values(array_filter(explode('/', $path)));
// Expecting segments like ['category', '{contentType}', '{...categorySlugs}']
if (count($segments) < 2 || strtolower($segments[0]) !== 'category') {
return view('legacy.placeholder');
}
$parts = array_slice($segments, 1);
// If first part is numeric, attempt id->category resolution and redirect to canonical slug URL
$first = $parts[0] ?? null;
if ($first !== null && ctype_digit((string) $first)) {
try {
$category = Category::findOrFail((int) $first);
$contentTypeSlug = $category->contentType->slug ?? null;
$canonical = '/' . strtolower($contentTypeSlug) . '/' . $category->full_slug_path;
return redirect($canonical, 301);
} catch (ModelNotFoundException $e) {
abort(404);
}
}
// Build slug list: first element is content type slug, rest are category slugs
$contentTypeSlug = array_shift($parts);
$slugs = array_merge([$contentTypeSlug], $parts);
$perPage = (int) $request->get('per_page', 40);
try {
$artworks = $this->artworkService->getArtworksByCategoryPath($slugs, $perPage);
} catch (ModelNotFoundException $e) {
abort(404);
}
// Resolve Category model for page meta and subcategories
// Use the contentType + path traversal to find the category
try {
$category = Category::whereHas('contentType', function ($q) use ($contentTypeSlug) {
$q->where('slug', strtolower($contentTypeSlug));
})->whereNull('parent_id')->where('slug', strtolower($parts[0] ?? ''))->first();
// If deeper path exists, traverse
if ($category && count($parts) > 1) {
$cur = $category;
foreach (array_slice($parts, 1) as $slugPart) {
$cur = $cur->children()->where('slug', strtolower($slugPart))->first();
if (! $cur) {
abort(404);
}
}
$category = $cur;
}
} catch (\Throwable $e) {
$category = null;
}
if (! $category) {
// Category resolution failed
abort(404);
}
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
$page_title = $category->name;
$page_meta_description = $category->description ?? ($category->contentType->name . ' artworks on Skinbase');
$page_meta_keywords = strtolower($category->contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
return view('legacy.category', compact(
'page_title',
'page_meta_description',
'page_meta_keywords',
'group',
'category',
'subcategories',
'artworks'
));
}
public function browseCategories()
{
$data = $this->legacy->browseCategories();
return view('legacy.categories', $data);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ChatController extends Controller
{
public function index(Request $request)
{
$page_title = 'Online Chat';
// Handle post (store chat)
$store = $request->input('store_chat');
$chat_text = $request->input('chat_txt');
$chat = new \App\Chat();
if (!empty($store) && $store === 'true' && !empty($chat_text)) {
if (!empty($_SESSION['web_login']['status'])) {
$chat->StoreMessage($chat_text);
$chat->UpdateChatFile('cron/chat_log.txt', 10);
}
}
// Capture Banner output
ob_start();
\App\Banner::ShowResponsiveAd();
$adHtml = ob_get_clean();
// Capture Chat HTML
ob_start();
$userID = $_SESSION['web_login']['user_id'] ?? null;
$chat->ShowChat(50, $userID);
$chatHtml = ob_get_clean();
// Load smileys from legacy DB
try {
$smileys = DB::connection('legacy')->table('smileys')->select('code', 'picture', 'emotion')->get();
} catch (\Throwable $e) {
$smileys = collect();
}
return view('legacy.chat', compact('page_title', 'adHtml', 'chatHtml', 'smileys'));
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class DailyUploadsController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
$isAjax = $request->boolean('ajax');
$datum = $request->query('datum');
if ($isAjax && $datum) {
// Return partial gallery for the given date
$arts = $this->fetchByDate($datum);
return view('legacy.partials.daily-uploads-grid', ['arts' => $arts])->render();
}
// Build date tabs (today .. -14 days)
$dates = [];
for ($x = 0; $x > -15; $x--) {
$ts = strtotime(sprintf('%+d days', $x));
$dates[] = [
'iso' => date('Y-m-d', $ts),
'label' => date('d. F Y', $ts),
];
}
// initial content: recent (last 7 days)
$recent = $this->fetchRecent();
return view('legacy.daily-uploads', [
'dates' => $dates,
'recent' => $recent,
'page_title' => 'Daily Uploads',
]);
}
private function fetchByDate(string $date)
{
$ars = Artwork::public()
->published()
->whereDate('published_at', $date)
->orderByDesc('published_at')
->with(['user:id,name', 'categories' => function ($q) {
$q->select('categories.id', 'categories.name', 'categories.sort_order');
}])
->get();
return $this->prepareArts($ars);
}
private function fetchRecent()
{
$start = now()->subDays(7)->startOfDay();
$ars = Artwork::public()
->published()
->where('published_at', '>=', $start)
->orderByDesc('published_at')
->with(['user:id,name', 'categories' => function ($q) {
$q->select('categories.id', 'categories.name', 'categories.sort_order');
}])
->get();
return $this->prepareArts($ars);
}
private function prepareArts($ars)
{
return $ars->map(function (Artwork $ar) {
$primaryCategory = $ar->categories->sortBy('sort_order')->first();
$present = \App\Services\ThumbnailPresenter::present($ar, 'md');
return (object) [
'id' => $ar->id,
'name' => $ar->title,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
'category_name' => $primaryCategory->name ?? '',
'uname' => $ar->user->name ?? 'Skinbase',
];
});
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class FeaturedArtworksController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
$perPage = 39;
$type = (int) ($request->query('type', 4));
$typeFilter = $type === 4 ? null : $type;
$artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage);
$artworks->getCollection()->transform(function (Artwork $artwork) {
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categoryName = $primaryCategory->name ?? '';
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
return (object) [
'id' => $artwork->id,
'name' => $artwork->title,
'category_name' => $categoryName,
'gid_num' => $gid,
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user->name ?? 'Skinbase',
];
});
$artworkTypes = [
1 => 'Bronze Awards',
2 => 'Silver Awards',
3 => 'Gold Awards',
4 => 'Featured Artworks',
];
$pageTitle = $artworkTypes[$type] ?? 'Featured Artworks';
return view('legacy.featured-artworks', [
'artworks' => $artworks,
'type' => $type,
'artworkTypes' => $artworkTypes,
'page_title' => $pageTitle,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
class ForumController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function index()
{
$data = $this->legacy->forumIndex();
return view('legacy.forum.index', $data);
}
public function topic(Request $request, $topic_id)
{
$data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1));
if (! $data) {
return view('legacy.placeholder');
}
if (isset($data['type']) && $data['type'] === 'subtopics') {
return view('legacy.forum.topic', $data);
}
return view('legacy.forum.posts', $data);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
class HomeController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
$page_title = 'Skinbase - Photography, Skins & Wallpapers';
$page_meta_description = 'Skinbase legacy home, rendered via Laravel.';
$page_meta_keywords = 'wallpapers, skins, photography, community';
// Use new ArtworkService as primary data source
$featuredResult = $this->artworks->getFeaturedArtworks(null, 39);
// If service returned a paginator, extract the first model for the single "featured" slot
if ($featuredResult instanceof \Illuminate\Pagination\LengthAwarePaginator) {
$featured = $featuredResult->getCollection()->first();
} elseif (is_array($featuredResult)) {
$featured = $featuredResult[0] ?? null;
} else {
// Collection or single item
$featured = method_exists($featuredResult, 'first') ? $featuredResult->first() : $featuredResult;
}
// Provide a memberFeatured fallback so the legacy view always has a value
$memberFeatured = $featured;
$latestUploads = $this->artworks->getLatestArtworks(20);
// Legacy forum/news data not available in new services yet — provide empty defaults
$forumNews = [];
$ourNews = [];
$latestForumActivity = [];
return view('legacy.home', compact(
'page_title',
'page_meta_description',
'page_meta_keywords',
'featured',
'memberFeatured',
'latestUploads',
'forumNews',
'ourNews',
'latestForumActivity'
));
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class InterviewController extends Controller
{
public function show(Request $request, $id, $slug = null)
{
$id = (int) $id;
// Handle comment POST
if ($request->isMethod('post')) {
$action = $request->input('action');
if ($action === 'store' && (!empty($_SESSION['web_login']['user_type']) && $_SESSION['web_login']['user_type'] > 1)) {
$comment = $request->input('comment');
$tekst = nl2br(htmlspecialchars($comment ?? '', ENT_QUOTES, 'UTF-8'));
$interviewId = (int) $request->input('interview_id');
try {
DB::connection('legacy')->table('interviews_comment')->insert([
'nid' => $interviewId,
'author' => $_SESSION['web_login']['username'] ?? 'Anonymous',
'datum' => DB::raw('CURRENT_TIMESTAMP'),
'tekst' => $tekst,
]);
$ar2 = DB::connection('legacy')->table('users')
->where('uname', $_SESSION['web_login']['username'])
->first();
if (!empty($ar2->user_id)) {
DB::connection('legacy')->table('users_statistics')
->where('user_id', $ar2->user_id)
->increment('newscomment');
}
} catch (\Throwable $e) {
// fail silently
}
}
}
try {
$ar = DB::connection('legacy')->table('interviews')->where('id', $id)->first();
} catch (\Throwable $e) {
$ar = null;
}
if (! $ar) {
return redirect('/interviews');
}
try {
$artworks = DB::connection('legacy')->table('wallz')
->where('uname', $ar->username)
->inRandomOrder()
->limit(2)
->get();
} catch (\Throwable $e) {
$artworks = collect();
}
try {
$comments = DB::connection('legacy')->table('interviews_comment as c')
->leftJoin('users as u', 'u.uname', '=', 'c.author')
->where('c.nid', $id)
->select('c.*', 'u.user_id', 'u.user_type', 'u.signature', 'u.icon')
->orderBy('c.datum')
->get();
} catch (\Throwable $e) {
$comments = collect();
}
// compute total posts per author across interviews_comment
$authors = $comments->pluck('author')->unique()->values()->all();
$postCounts = [];
if (!empty($authors)) {
try {
$counts = DB::connection('legacy')->table('interviews_comment')
->select('author', DB::raw('COUNT(*) as cnt'))
->whereIn('author', $authors)
->groupBy('author')
->get();
foreach ($counts as $c) {
$postCounts[$c->author] = $c->cnt;
}
} catch (\Throwable $e) {
// ignore
}
}
$page_title = 'Interview with ' . ($ar->username ?? '');
return view('legacy.interview', [
'ar' => $ar,
'artworks' => $artworks,
'comments' => $comments,
'postCounts' => $postCounts,
'page_title' => $page_title,
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class InterviewsController extends Controller
{
public function index(Request $request)
{
try {
$interviews = DB::connection('legacy')->table('interviews AS t1')
->select('t1.id', 't1.headline', 't2.user_id', 't2.uname', 't2.icon')
->leftJoin('users AS t2', 't1.username', '=', 't2.uname')
->orderByDesc('t1.datum')
->limit(60)
->get();
} catch (\Throwable $e) {
$interviews = collect();
}
$page_title = 'Interviews';
return view('legacy.interviews', compact('interviews', 'page_title'));
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ArtworkComment;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class LatestCommentsController extends Controller
{
public function index(Request $request)
{
$hits = 20;
// Join artwork_comments -> artwork -> user, but only include artworks that are public, approved and published
$query = ArtworkComment::with(['user', 'artwork'])
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->orderByDesc('created_at');
$comments = $query->paginate($hits)->withQueryString();
// Shape results for legacy view
$comments->getCollection()->transform(function (ArtworkComment $c) {
$art = $c->artwork;
$user = $c->user;
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg';
return (object) [
'comment_id' => $c->getKey(),
'comment_description' => $c->content,
'commenter_id' => $c->user_id,
'country' => $user->country ?? null,
'icon' => $user->avatar ?? null,
'uname' => $user->username ?? $user->name ?? 'User',
'signature' => $user->signature ?? null,
'user_type' => $user->role ?? null,
'id' => $art->id ?? null,
'name' => $art->title ?? null,
'picture' => $art->file_name ?? null,
'thumb' => $thumb,
'artwork_slug' => $art->slug ?? Str::slug($art->title ?? ''),
'datetime' => $c->created_at?->toDateTimeString() ?? now()->toDateTimeString(),
];
});
$page_title = 'Latest Comments';
return view('legacy.latest-comments', compact('page_title', 'comments'));
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Facades\Storage;
class LatestController extends Controller
{
protected ArtworkService $artworks;
public function __construct(ArtworkService $artworks)
{
$this->artworks = $artworks;
}
public function index(Request $request)
{
$perPage = 21;
/** @var CursorPaginator $artworks */
$artworks = $this->artworks->browsePublicArtworks($perPage);
// Shape data for legacy view without legacy tables.
$artworks->getCollection()->transform(function (Artwork $artwork) {
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$categoryName = $primaryCategory->name ?? '';
$gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0;
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
return (object) [
'id' => $artwork->id,
'name' => $artwork->title,
'category_name' => $categoryName,
'gid_num' => $gid,
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user->name ?? 'Skinbase',
];
});
return view('legacy.latest-artworks', [
'artworks' => $artworks,
'page_title' => 'Latest Artworks',
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
use Illuminate\Support\Str;
class MembersController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function photos(Request $request, $id = null)
{
$id = (int) ($id ?: 545);
$result = $this->legacy->categoryPage('', null, $id);
if (! $result) {
return redirect('/');
}
// categoryPage returns an array with keys used by legacy.browse
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos');
$artworks = $result['artworks'] ?? collect();
// Ensure artworks include `slug`, `thumb`, and `thumb_srcset` properties expected by the legacy view
if ($artworks && method_exists($artworks, 'getCollection')) {
$artworks->getCollection()->transform(function ($row) {
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
return $row;
});
} elseif (is_iterable($artworks)) {
$artworks = collect($artworks)->map(function ($row) {
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
return $row;
});
}
return view('legacy.browse', compact('page_title', 'artworks'));
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MonthlyCommentatorsController extends Controller
{
public function index(Request $request)
{
$hits = 30;
$page = max(1, (int) $request->query('page', 1));
$query = DB::connection('legacy')->table('artworks_comments as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->leftJoin('country as c', 't2.country', '=', 'c.id')
->where('t1.user_id', '>', 0)
->whereRaw("DATE_SUB(CURDATE(), INTERVAL 30 DAY) <= t1.date")
->select(
't2.user_id',
't2.uname',
't2.user_type',
't2.country',
'c.name as country_name',
'c.flag as country_flag',
DB::raw('COUNT(*) as num_comments')
)
->groupBy('t1.user_id')
->orderByDesc('num_comments');
$rows = $query->paginate($hits)->withQueryString();
$page_title = 'Monthly Top Commentators';
return view('legacy.monthly-commentators', compact('page_title', 'rows'));
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class NewsController extends Controller
{
public function show(Request $request, $id, $slug = null)
{
$id = (int) $id;
try {
$news = DB::connection('legacy')->table('news as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->where('t1.news_id', $id)
->select('t1.*', 't2.uname', 't2.user_type', 't2.signature', 't2.icon')
->first();
} catch (\Throwable $e) {
$news = null;
}
if (empty($news)) {
return redirect('/');
}
try {
$comments = DB::connection('legacy')->table('news_comment as c')
->leftJoin('users as u', 'c.user_id', '=', 'u.user_id')
->where('c.news_id', $id)
->select('c.posted', 'c.message', 'c.user_id', 'u.user_type', 'u.signature', 'u.icon', 'u.uname')
->orderBy('c.posted')
->get();
} catch (\Throwable $e) {
$comments = collect();
}
$page_title = ($news->headline ?? 'News') . ' - SkinBase News';
return view('legacy.news', compact('news', 'comments', 'page_title'));
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Services\ArtworkService;
use App\Models\User;
use App\Models\Artwork;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Auth;
class ProfileController extends Controller
{
protected ArtworkService $artworkService;
public function __construct(ArtworkService $artworkService)
{
$this->artworkService = $artworkService;
}
public function show(Request $request, ?int $id = null, ?string $slug = null)
{
// Support /profile (current user) and /profile/{id}/{slug}
$id = $id ?? (Auth::check() ? Auth::id() : null);
if (! $id) {
abort(404);
}
$user = User::find($id);
if (! $user) {
abort(404);
}
// Determine visibility: owner sees all, others only public+approved+published
$isOwner = Auth::check() && Auth::id() === $user->id;
$perPage = 24;
// Use ArtworkService to fetch artworks for the profile
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage);
// Shape data for legacy view expectations
$artworks->getCollection()->transform(function (Artwork $art) {
$present = \App\Services\ThumbnailPresenter::present($art, 'md');
return (object) [
'id' => $art->id,
'name' => $art->title,
'picture' => $art->file_name,
'datum' => $art->published_at,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $art->user->name ?? 'Skinbase',
];
});
// Map new User model to legacy view shape expected by templates
$legacyUser = (object) [
'user_id' => $user->id,
'uname' => $user->name,
'real_name' => $user->name,
'icon' => $user->avatar ?? null,
'about_me' => $user->bio ?? null,
];
return view('legacy.profile', [
'user' => $legacyUser,
'artworks' => $artworks,
]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ArtworkDownload;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Carbon\Carbon;
class TodayDownloadsController extends Controller
{
public function index(Request $request)
{
$hits = 30;
// Filter downloads created today and join to artworks that are public, approved and published
$today = Carbon::now()->toDateString();
$query = ArtworkDownload::with(['artwork'])
->whereDate('created_at', $today)
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->selectRaw('artwork_id, COUNT(*) as num_downloads')
->groupBy('artwork_id')
->orderByDesc('num_downloads');
$paginator = $query->paginate($hits)->withQueryString();
// Map to the legacy browse shape
$paginator->getCollection()->transform(function ($row) {
// $row is a stdClass with artwork_id and num_downloads
$art = $row->artwork ?? null;
// If Eloquent didn't eager load artwork (group queries sometimes don't), fetch it
if (! $art && isset($row->artwork_id)) {
$art = \App\Models\Artwork::find($row->artwork_id);
}
$name = $art->title ?? null;
$picture = $art->file_name ?? null;
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$encoded = null; // legacy encoding unavailable; leave null
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? $present['url'] : '/gfx/sb_join.jpg';
$categoryId = $art->categories->first()->id ?? null;
return (object) [
'id' => $art->id ?? null,
'name' => $name,
'picture' => $picture,
'slug' => $art->slug ?? Str::slug($name ?? ''),
'ext' => $ext,
'encoded' => $encoded,
'thumb' => $thumb,
'thumb_srcset' => $thumb,
'category' => $categoryId,
'num_downloads' => $row->num_downloads ?? 0,
'gid_num' => $categoryId ? ((int) $categoryId % 5) * 5 : 0,
];
});
$page_title = 'Today Downloaded Artworks';
return view('legacy.browse', ['page_title' => $page_title, 'artworks' => $paginator]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TodayInHistoryController extends Controller
{
public function index(Request $request)
{
$hits = 39;
try {
$base = DB::connection('legacy')->table('featured_works as t0')
->leftJoin('artworks as t1', 't0.artwork_id', '=', 't1.id')
->join('artworks_categories as t2', 't1.category', '=', 't2.category_id')
->where('t1.approved', 1)
->whereRaw('MONTH(t0.post_date) = MONTH(CURRENT_DATE())')
->whereRaw('DAY(t0.post_date) = DAY(CURRENT_DATE())')
->select('t1.id', 't1.name', 't1.picture', 't1.uname', 't1.category', 't2.category_name');
$artworks = $base->orderBy('t0.post_date','desc')->paginate($hits);
} catch (\Throwable $e) {
$artworks = null;
}
if ($artworks && method_exists($artworks, 'getCollection')) {
$artworks->getCollection()->transform(function ($row) {
$row->ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$row->encoded = \App\Services\LegacyService::encode($row->id);
// Prefer new CDN when artwork exists with hash
try {
$art = \App\Models\Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$row->thumb_url = $present['url'];
$row->thumb_srcset = $present['srcset'];
} catch (\Throwable $e) {
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
$row->thumb_url = $present['url'];
$row->thumb_srcset = $present['srcset'];
}
$row->gid_num = ((int)($row->category ?? 0) % 5) * 5;
return $row;
});
}
return view('legacy.today-in-history', [
'artworks' => $artworks,
'page_title' => 'Popular on this day in history',
]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Models\Artwork;
use App\Models\ArtworkStats;
use App\Models\User;
class TopAuthorsController extends Controller
{
public function index(Request $request)
{
$perPage = 20;
$metric = strtolower($request->query('metric', 'views'));
if (! in_array($metric, ['views', 'downloads'])) {
$metric = 'views';
}
// Aggregate artwork_stats grouped by artwork.user_id, filtering only public+approved+published artworks
$sub = Artwork::query()
->select('artworks.user_id')
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now())
->whereNull('artworks.deleted_at')
->selectRaw('artworks.user_id, SUM(artwork_stats.' . $metric . ') as total_metric, MAX(artworks.published_at) as latest_published')
->groupBy('artworks.user_id');
// Join with users to fetch profile info
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
->mergeBindings($sub->getQuery())
->join('users as u', 'u.id', '=', 't.user_id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
->orderByDesc('t.total_metric')
->orderByDesc('t.latest_published');
$authors = $query->paginate($perPage)->withQueryString();
// Map to legacy view shape
$authors->getCollection()->transform(function ($row) use ($metric) {
return (object) [
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
'total' => (int) $row->total_metric,
'metric' => $metric,
];
});
$page_title = 'Top Authors';
return view('legacy.top-authors', compact('page_title', 'authors', 'metric'));
}
}
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TopAuthorsController extends Controller
{
public function index(Request $request)
{
// Top users (most active)
try {
$topUsers = DB::connection('legacy')->table('wallz as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->select('t2.user_id', 't2.uname', 't2.icon', DB::raw('SUM(t1.dls) AS total_downloads'), DB::raw('COUNT(*) AS uploads'))
->groupBy('t1.user_id')
->orderByDesc('total_downloads')
->limit(23)
->get();
} catch (\Throwable $e) {
$topUsers = collect();
}
// Top followers
try {
$topFollowers = DB::connection('legacy')->table('friends_list as t1')
->rightJoin('users as t2', 't1.friend_id', '=', 't2.user_id')
->where('t1.friend_id', '>', 0)
->select('t2.uname', 't2.user_id', DB::raw('COUNT(*) as num'))
->groupBy('t1.friend_id')
->orderByDesc('num')
->limit(10)
->get();
} catch (\Throwable $e) {
$topFollowers = collect();
}
// Top commentators
try {
$topCommentators = DB::connection('legacy')->table('artworks_comments as t1')
->join('users as t2', 't1.user_id', '=', 't2.user_id')
->where('t1.user_id', '>', 0)
->select('t2.user_id','t2.uname','t2.user_type','t2.country', DB::raw('COUNT(*) as num_comments'))
->groupBy('t1.user_id')
->orderByDesc('num_comments')
->limit(10)
->get();
// enrich with country info if available
$topCommentators->transform(function ($c) {
if (!empty($c->country)) {
$cn = DB::connection('legacy')->table('country')->select('name','flag')->where('id', $c->country)->first();
$c->country_name = $cn->name ?? null;
$c->country_flag = $cn->flag ?? null;
} else {
$c->country_name = null;
$c->country_flag = null;
}
return $c;
});
} catch (\Throwable $e) {
$topCommentators = collect();
}
return view('legacy.top-authors', compact('topUsers', 'topFollowers', 'topCommentators'));
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Services\LegacyService;
class TopFavouritesController extends Controller
{
public function index(Request $request)
{
$hits = 21;
$page = max(1, (int) $request->query('page', 1));
$base = DB::connection('legacy')->table('artworks_favourites as t1')
->rightJoin('wallz as t2', 't1.artwork_id', '=', 't2.id')
->where('t2.approved', 1)
->select('t2.id', 't2.name', 't2.picture', 't2.category', DB::raw('COUNT(*) as num'))
->groupBy('t1.artwork_id');
try {
$paginator = (clone $base)->orderBy('num', 'desc')->paginate($hits)->withQueryString();
} catch (\Throwable $e) {
$paginator = collect();
}
// Map artworks to include expected properties for legacy card view
if ($paginator && method_exists($paginator, 'getCollection')) {
$paginator->getCollection()->transform(function ($row) {
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
$ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$encoded = \App\Helpers\Thumb::encodeId((int) $row->id);
$row->encoded = $encoded;
$row->ext = $ext;
try {
$art = \App\Models\Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$row->thumb = $row->thumb ?? $present['url'];
$row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']);
} catch (\Throwable $e) {
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
$row->thumb = $row->thumb ?? $present['url'];
$row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']);
}
$row->gid_num = ((int)($row->category ?? 0) % 5) * 5;
return $row;
});
}
$page_title = 'Top Favourites';
return view('legacy.top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
}
}

View File

@@ -0,0 +1,564 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class LegacyController extends Controller
{
public function index(Request $request)
{
$page_title = 'Skinbase - Photography, Skins & Wallpapers';
$page_meta_description = 'Skinbase legacy home, rendered via Laravel.';
$page_meta_keywords = 'wallpapers, skins, photography, community';
[$featured, $memberFeatured] = $this->featured();
$latestUploads = $this->latestUploads();
$forumNews = $this->forumNews();
$ourNews = $this->ourNews();
$latestForumActivity = $this->latestForumActivity();
return view('legacy.home', compact(
'page_title',
'page_meta_description',
'page_meta_keywords',
'featured',
'memberFeatured',
'latestUploads',
'forumNews',
'ourNews',
'latestForumActivity'
));
}
public function browse(Request $request)
{
$page_title = 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase';
$page_meta_description = "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiastse.";
$page_meta_keywords = 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo';
$perPage = 50;
try {
$artworks = DB::connection('legacy')->table('wallz as w')
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->select('w.id', 'w.name', 'w.picture', 'w.category', 'w.datum', 'c.category_name', 'u.uname')
->where('w.approved', 1)
->where('w.public', 'Y')
->orderByDesc('w.datum')
->paginate($perPage)
->withQueryString();
} catch (\Throwable $e) {
$placeholder = collect([
(object) [
'id' => 0,
'name' => 'Sample Artwork',
'picture' => 'gfx/sb_join.jpg',
'category' => null,
'datum' => now(),
'category_name' => 'Photography',
'uname' => 'Skinbase',
],
]);
$artworks = new LengthAwarePaginator(
$placeholder,
$placeholder->count(),
$perPage,
1,
['path' => $request->url(), 'query' => $request->query()]
);
}
return view('legacy.browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'artworks'));
}
public function category(Request $request, string $group, ?string $slug = null, ?int $id = null)
{
$group = Str::title($group);
$defaults = [
'Skins' => 1,
'Wallpapers' => 2,
'Photography' => 3,
'Other' => 4,
];
if (!$id && $slug && ctype_digit($slug)) {
$id = (int) $slug;
}
$id = $id ?: ($defaults[$group] ?? null);
if (!$id || $id < 1) {
return redirect('/');
}
$page_title = $group;
$page_meta_description = $group . ' artworks on Skinbase';
$page_meta_keywords = strtolower($group) . ', skinbase, artworks, wallpapers, photography, skins';
try {
$category = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name', 'description', 'rootid', 'section_id')
->where('category_id', $id)
->first();
} catch (\Throwable $e) {
$category = null;
}
if (!$category) {
return redirect('/');
}
$perPage = 40;
try {
$base = DB::connection('legacy')->table('wallz as t1')
->select('t1.id', 't1.name', 't1.picture', 't3.uname', 't1.category', 't2.category_name')
->join('artworks_categories as t2', 't1.category', '=', 't2.category_id')
->leftJoin('users as t3', 't1.user_id', '=', 't3.user_id')
->where('t1.approved', 1)
->where(function ($q) use ($id, $category) {
$q->where('t1.category', (int) $id);
if ($category->rootid > 0) {
$q->orWhere('t1.rootid', (int) $id);
}
})
->orderByDesc('t1.datum');
$artworks = $base->paginate($perPage)->withQueryString();
} catch (\Throwable $e) {
$artworks = new LengthAwarePaginator([], 0, $perPage, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
}
try {
$subcategories = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name')
->where('rootid', $id)
->orderBy('category_name')
->get();
if ($subcategories->isEmpty() && $category->rootid) {
$subcategories = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name')
->where('rootid', $category->rootid)
->orderBy('category_name')
->get();
}
if ($subcategories->isEmpty()) {
$subcategories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name')
->where('rootid', $id)
->orderBy('category_name')
->get();
}
} catch (\Throwable $e) {
try {
$subcategories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name')
->where('rootid', $id)
->orderBy('category_name')
->get();
} catch (\Throwable $e2) {
$subcategories = collect();
}
}
return view('legacy.category', compact(
'group',
'category',
'artworks',
'subcategories',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
public function browseCategories()
{
$page_title = 'Browse Categories';
$page_meta_description = 'Browse categories across Photography, Wallpapers, Skins and more on Skinbase.';
$page_meta_keywords = 'categories, photography, wallpapers, skins, browse';
// Load top-level categories (section_id = 0 AND rootid = 0) like the legacy page
try {
$categories = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name', 'description')
->where('section_id', 0)
->where('rootid', 0)
->orderBy('category_id')
->get();
// Fallback to legacy table name if empty
if ($categories->isEmpty()) {
$categories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name', 'description')
->where('section_id', 0)
->where('rootid', 0)
->orderBy('category_id')
->get();
}
} catch (\Throwable $e) {
try {
$categories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name', 'description')
->where('section_id', 0)
->where('rootid', 0)
->orderBy('category_id')
->get();
} catch (\Throwable $e2) {
$categories = collect();
}
}
// Fetch all subcategories in one query to avoid N+1 and group them by parent (section_id)
$subgroups = collect();
if ($categories->isNotEmpty()) {
$ids = $categories->pluck('category_id')->unique()->values()->all();
try {
$subs = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name', 'picture', 'section_id')
->whereIn('section_id', $ids)
->orderBy('category_name')
->get();
if ($subs->isEmpty()) {
// fallback to skupine table naming
$subs = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name', 'picture', 'section_id')
->whereIn('section_id', $ids)
->orderBy('category_name')
->get();
}
$subgroups = $subs->groupBy('section_id');
} catch (\Throwable $e) {
$subgroups = collect();
}
}
return view('legacy.categories', compact(
'categories',
'subgroups',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
public function forumIndex()
{
$page_title = 'Forum';
$page_meta_description = 'Skinbase forum threads.';
$page_meta_keywords = 'forum, discussions, topics, skinbase';
try {
$topics = DB::connection('legacy')->table('forum_topics as t')
->select(
't.topic_id',
't.topic',
't.discuss',
't.last_update',
't.privilege',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
)
->where('t.root_id', 0)
->where('t.privilege', '<', 4)
->orderByDesc('t.last_update')
->limit(100)
->get();
} catch (\Throwable $e) {
$topics = collect();
}
return view('legacy.forum.index', compact(
'topics',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
public function forumTopic(Request $request, int $topic_id)
{
try {
$topic = DB::connection('legacy')->table('forum_topics')->where('topic_id', $topic_id)->first();
} catch (\Throwable $e) {
$topic = null;
}
if (!$topic) {
return redirect('/forum');
}
$page_title = $topic->topic;
$page_meta_description = Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160);
$page_meta_keywords = 'forum, topic, skinbase';
// Fetch subtopics; if none exist, fall back to posts (matches legacy behavior where some topics hold posts directly)
try {
$subtopics = DB::connection('legacy')->table('forum_topics as t')
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
->select(
't.topic_id',
't.topic',
't.discuss',
't.post_date',
't.last_update',
'u.uname',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
)
->where('t.root_id', $topic->topic_id)
->orderByDesc('t.last_update')
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$subtopics = new LengthAwarePaginator([], 0, 50, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
}
if ($subtopics->total() > 0) {
return view('legacy.forum.topic', compact(
'topic',
'subtopics',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
$sort = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
// First try topic_id; if empty, retry using legacy tid column
$posts = new LengthAwarePaginator([], 0, 50, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
try {
$posts = DB::connection('legacy')->table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.topic_id', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
// will retry with tid
}
if ($posts->total() === 0) {
try {
$posts = DB::connection('legacy')->table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.tid', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
// keep empty paginator
}
}
return view('legacy.forum.posts', compact(
'topic',
'posts',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
/**
* Fetch featured artworks with graceful fallbacks.
*/
private function featured(): array
{
$featured = null;
$memberFeatured = null;
try {
$featured = DB::connection('legacy')->table('featured_works as fw')
->leftJoin('wallz as w', 'fw.artwork_id', '=', 'w.id')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->select('w.id', 'w.name', 'w.picture', 'u.uname', 'fw.post_date')
->orderByDesc('fw.post_date')
->first();
$memberFeatured = DB::connection('legacy')->table('users_opinions as o')
->leftJoin('wallz as w', 'o.artwork_id', '=', 'w.id')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->select(DB::raw('COUNT(*) AS votes'), 'w.id', 'w.name', 'w.picture', 'u.uname')
->whereRaw('o.post_date > SUBDATE(CURRENT_DATE(), INTERVAL 30 DAY)')
->where('o.score', 4)
->groupBy('o.artwork_id', 'w.id', 'w.name', 'w.picture', 'u.uname')
->orderByDesc('votes')
->limit(1)
->first();
} catch (\Throwable $e) {
// Fail soft; render placeholders
}
if (!$featured) {
$featured = (object) [
'id' => 0,
'name' => 'Featured Artwork',
'picture' => '/gfx/sb_join.jpg',
'uname' => 'Skinbase',
];
}
if (!$memberFeatured) {
$memberFeatured = (object) [
'id' => 0,
'name' => 'Members Pick',
'picture' => '/gfx/sb_join.jpg',
'uname' => 'Skinbase',
'votes' => 0,
];
}
return [$featured, $memberFeatured];
}
private function forumNews(): array
{
try {
return DB::connection('legacy')->table('forum_topics as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->select(
't1.topic_id',
't1.topic',
't1.views',
't1.post_date',
't1.preview',
't2.uname'
)
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.post_date')
->limit(8)
->get()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
private function ourNews(): array
{
try {
return DB::connection('legacy')->table('news as t1')
->join('news_categories as t2', 't1.category_id', '=', 't2.category_id')
->join('users as t3', 't1.user_id', '=', 't3.user_id')
->select(
't1.news_id',
't1.headline',
't1.picture',
't1.preview',
't1.create_date',
't1.views',
't2.category_name',
't3.uname',
DB::raw('(SELECT COUNT(*) FROM news_comment WHERE news_id = t1.news_id) AS num_comments')
)
->orderByDesc('t1.create_date')
->limit(5)
->get()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
private function latestForumActivity(): array
{
try {
return DB::connection('legacy')->table('forum_topics as t1')
->select(
't1.topic_id',
't1.topic',
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
)
->where('t1.root_id', '<>', 0)
->where('t1.root_id', '<>', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.last_update')
->orderByDesc('t1.post_date')
->limit(10)
->get()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
/**
* Load latest uploads either from cached JSON or DB.
*/
private function latestUploads(): array
{
$uploads = [];
// Try cache file first
$cachePath = base_path('oldSite/www/cache/latest_uploads.json');
if (File::exists($cachePath)) {
$json = File::get($cachePath);
$uploads = json_decode($json, true) ?: [];
}
// Fallback to DB if cache missing
if (empty($uploads)) {
try {
$uploads = DB::connection('legacy')->table('wallz as w')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
->where('w.approved', 1)
->orderByDesc('w.datum')
->limit(20)
->get()
->map(function ($row) {
return [
'id' => $row->id,
'name' => $row->name,
'picture' => $row->picture,
'uname' => $row->uname,
'category_name' => $row->category_name ?? '',
];
})
->toArray();
} catch (\Throwable $e) {
// Soft fail
$uploads = [];
}
}
// Final fallback placeholders
if (empty($uploads)) {
$uploads = [
[
'id' => 1,
'name' => 'Sample Artwork',
'picture' => 'gfx/sb_join.jpg',
'uname' => 'Skinbase',
'category_name' => 'Photography',
],
];
}
return $uploads;
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\ArtworkCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
class ManageController extends Controller
{
public function index(Request $request)
{
$userId = $request->user()->id;
$perPage = 50;
// Use legacy connection query builder and join category name to avoid Eloquent model issues
$query = DB::connection('legacy')->table('artworks as a')
->leftJoin('artworks_categories as c', 'a.category', '=', 'c.category_id')
->where('a.user_id', $userId)
->select('a.*', 'c.category_name')
->orderByDesc('a.datum')
->orderByDesc('a.id');
$artworks = $query->paginate($perPage);
return view('manage.index', [
'artworks' => $artworks,
'page_title' => 'Artwork Manager',
]);
}
public function edit(Request $request, $id)
{
$userId = $request->user()->id;
$artwork = DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
if (! $artwork) {
abort(404);
}
$categories = DB::connection('legacy')->table('artworks_categories')->where('section_id', 0)->orderBy('category_id')->get();
return view('manage.edit', [
'artwork' => $artwork,
'categories' => $categories,
'page_title' => 'Edit Artwork: ' . ($artwork->name ?? ''),
]);
}
public function update(Request $request, $id)
{
$userId = $request->user()->id;
$existing = DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
if (! $existing) {
abort(404);
}
$data = $request->validate([
'name' => 'required|string|max:255',
'section' => 'nullable|integer',
'description' => 'nullable|string',
'artwork' => 'nullable|file|image',
'attachment' => 'nullable|file',
]);
$update = [
'name' => $data['name'],
'category' => $data['section'] ?? $existing->category,
'description' => $data['description'] ?? $existing->description,
'updated' => now(),
];
// handle artwork image upload (replacing picture)
if ($request->hasFile('artwork')) {
$file = $request->file('artwork');
$path = $file->store('public/uploads/artworks');
$filename = basename($path);
$update['picture'] = $filename;
}
// handle attachment upload (zip, etc.)
if ($request->hasFile('attachment')) {
$att = $request->file('attachment');
$attPath = $att->store('public/uploads/attachments');
$update['fname'] = basename($attPath);
}
DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->update($update);
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
}
public function destroy(Request $request, $id)
{
$userId = $request->user()->id;
$artwork = DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
if (! $artwork) {
abort(404);
}
// delete files if present (stored in new storage location)
if (!empty($artwork->fname)) {
Storage::delete('public/uploads/attachments/' . $artwork->fname);
}
if (!empty($artwork->picture)) {
Storage::delete('public/uploads/artworks/' . $artwork->picture);
}
DB::connection('legacy')->table('artworks')->where('id', (int)$id)->where('user_id', $userId)->delete();
return redirect()->route('manage')->with('status', 'Artwork deleted.');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
// Soft-delete the user (preserve record) — align with soft-delete policy.
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\ArtworkService;
use App\Models\Category;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ArtworkController extends Controller
{
protected ArtworkService $service;
public function __construct(ArtworkService $service)
{
$this->service = $service;
}
/**
* Browse artworks for a category (Blade view).
*/
public function category(Request $request, Category $category): View
{
$perPage = (int) $request->get('per_page', 24);
$artworks = $this->service->getCategoryArtworks($category, $perPage);
return view('artworks.index', [
'artworks' => $artworks,
'category' => $category,
]);
}
/**
* Show single artwork page by slug (Blade view).
*/
public function show(string $slug): View
{
try {
$artwork = $this->service->getPublicArtworkBySlug($slug);
} catch (ModelNotFoundException $e) {
abort(404);
}
// Prepare simple SEO meta data for Blade; keep controller thin.
$meta = [
'title' => $artwork->title,
'description' => str(config($artwork->description ?? ''))->limit(160),
'canonical' => $artwork->canonical_url ?? null,
];
return view('artworks.show', [
'artwork' => $artwork,
'meta' => $meta,
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ArtworkIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'per_page' => 'nullable|integer|min:1|max:100',
'sort' => 'nullable|in:latest,oldest',
'q' => 'nullable|string|max:255',
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Resources\MissingValue;
class ArtworkListResource extends JsonResource
{
/**
* Transform the resource into an array for listings (browse feed).
*/
public function toArray($request): array
{
if ($this instanceof MissingValue || $this->resource instanceof MissingValue) {
return [];
}
// Safe accessor to avoid magic __get which may trigger MissingValue errors
$get = function ($key) {
$r = $this->resource;
if ($r instanceof MissingValue || $r === null) {
return null;
}
if (method_exists($r, 'getAttribute')) {
return $r->getAttribute($key);
}
if (is_array($r)) {
return $r[$key] ?? null;
}
if (is_object($r)) {
return $r->{$key} ?? null;
}
return null;
};
$primaryCategory = $this->whenLoaded('categories', function () {
return $this->categories->sortBy('sort_order')->first();
});
// Normalize MissingValue into null so later checks are straightforward
if ($primaryCategory instanceof MissingValue) {
$primaryCategory = null;
}
$contentTypeSlug = null;
$categoryPath = null;
if ($primaryCategory) {
$contentTypeSlug = optional($primaryCategory->contentType)->slug ?? null;
$categoryPath = $primaryCategory->full_slug_path ?? null;
}
$slugVal = $get('slug');
$webUrl = $contentTypeSlug && $categoryPath && $slugVal
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
: null;
return [
'slug' => $slugVal,
'title' => $get('title'),
'description' => $this->when($request->boolean('include_description'), fn() => $get('description')),
'dimensions' => [
'width' => $get('width'),
'height' => $get('height'),
],
'thumbnail_url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))),
'author' => $this->whenLoaded('user', function () {
return [
'name' => $this->user->name ?? null,
];
}),
'category' => $primaryCategory ? [
'slug' => $primaryCategory->slug ?? null,
'name' => $primaryCategory->name ?? null,
'content_type' => $contentTypeSlug,
'url' => $webUrl,
] : null,
'urls' => [
'web' => $webUrl,
'canonical' => $webUrl,
],
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Resources\MissingValue;
class ArtworkResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray($request): array
{
if ($this instanceof MissingValue || $this->resource instanceof MissingValue) {
return [];
}
$get = function ($key) {
$r = $this->resource;
if ($r instanceof MissingValue || $r === null) {
return null;
}
// Eloquent model: prefer getAttribute to avoid magic proxies
if (method_exists($r, 'getAttribute')) {
return $r->getAttribute($key);
}
if (is_array($r)) {
return $r[$key] ?? null;
}
if (is_object($r)) {
return $r->{$key} ?? null;
}
return null;
};
return [
'slug' => $get('slug'),
'title' => $get('title'),
'description' => $get('description'),
'width' => $get('width'),
'height' => $get('height'),
// File URLs: produce public URLs without exposing internal file_path
'file' => [
'name' => $get('file_name') ?? null,
'url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))),
'size' => $get('file_size') ?? null,
'mime_type' => $get('mime_type') ?? null,
],
'categories' => $this->whenLoaded('categories', function () {
return $this->categories->map(fn($c) => [
'slug' => $c->slug ?? null,
'name' => $c->name ?? null,
])->values();
}),
'published_at' => $this->whenNotNull($get('published_at') ? $this->published_at->toAtomString() : null),
'urls' => [
'canonical' => $get('canonical_url') ?? null,
],
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Jobs;
use App\Services\ArtworkStatsService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class IncrementArtworkView implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $artworkId;
public int $count;
public string $eventId;
/**
* Require a unique event id to make the job idempotent across retries and concurrency.
*
* @param int $artworkId
* @param string $eventId Unique identifier for this view event (caller must supply)
* @param int $count
*/
public function __construct(int $artworkId, string $eventId, int $count = 1)
{
$this->artworkId = $artworkId;
$this->count = max(1, $count);
$this->eventId = $eventId;
}
/**
* Execute the job.
* Uses Redis setnx to ensure only one worker processes a given eventId.
* Delegates actual DB mutation to ArtworkStatsService which uses transactions.
*/
public function handle(ArtworkStatsService $statsService): void
{
$key = 'artwork:view:processed:' . $this->eventId;
try {
$didSet = false;
try {
$didSet = Redis::setnx($key, 1);
if ($didSet) {
// expire after 1 day to limit key growth
Redis::expire($key, 86400);
}
} catch (\Throwable $e) {
Log::warning('Redis unavailable for IncrementArtworkView; proceeding without dedupe', ['error' => $e->getMessage()]);
// If Redis is not available, fall back to applying delta directly.
// This sacrifices idempotency but ensures metrics are recorded.
$statsService->applyDelta($this->artworkId, ['views' => $this->count]);
return;
}
if (! $didSet) {
// Already processed this eventId — idempotent skip
return;
}
// Safe increment using transactional method
$statsService->applyDelta($this->artworkId, ['views' => $this->count]);
} catch (\Throwable $e) {
Log::error('IncrementArtworkView job failed', ['artwork_id' => $this->artworkId, 'event_id' => $this->eventId, 'error' => $e->getMessage()]);
// Let the job be retried by throwing
throw $e;
}
}
}

178
app/Models/Artwork.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* App\Models\Artwork
*
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkTranslation[] $translations
* @property-read ArtworkStats $stats
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkComment[] $comments
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkDownload[] $downloads
*/
class Artwork extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'artworks';
protected $fillable = [
'user_id',
'title',
'slug',
'description',
'file_name',
'file_path',
'hash',
'file_ext',
'thumb_ext',
'file_size',
'mime_type',
'width',
'height',
'is_public',
'is_approved',
'published_at',
'hash',
'thumb_ext',
'file_ext'
];
protected $casts = [
'is_public' => 'boolean',
'is_approved' => 'boolean',
'published_at' => 'datetime',
];
/**
* Thumbnail sizes and their options.
* Keys are the size dir used in the CDN URL.
*/
protected const THUMB_SIZES = [
'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'],
'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'],
'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'],
'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'],
];
/**
* Build the thumbnail URL for this artwork.
* Returns null when no hash or thumb_ext is available.
*/
public function thumbUrl(string $size = 'md'): ?string
{
if (empty($this->hash) || empty($this->thumb_ext)) {
return null;
}
$size = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
$h = $this->hash;
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
$ext = $this->thumb_ext;
return "https://files.skinbase.org/{$size}/{$h1}/{$h2}/{$h}.{$ext}";
}
/**
* Accessor for `$art->thumb` used in legacy views (default medium size).
*/
public function getThumbAttribute(): string
{
return $this->thumbUrl('md') ?? '/gfx/sb_join.jpg';
}
/**
* Accessor for `$art->thumb_url` used in some views.
*/
public function getThumbUrlAttribute(): ?string
{
return $this->thumbUrl('md');
}
/**
* Provide a responsive `srcset` for legacy views.
*/
public function getThumbSrcsetAttribute(): ?string
{
if (empty($this->hash) || empty($this->thumb_ext)) return null;
$sm = $this->thumbUrl('sm');
$md = $this->thumbUrl('md');
if (!$sm || !$md) return null;
return $sm . ' 320w, ' . $md . ' 600w';
}
// Relations
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function translations(): HasMany
{
return $this->hasMany(ArtworkTranslation::class);
}
public function stats(): HasOne
{
return $this->hasOne(ArtworkStats::class, 'artwork_id');
}
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id');
}
public function comments(): HasMany
{
return $this->hasMany(ArtworkComment::class);
}
public function downloads(): HasMany
{
return $this->hasMany(ArtworkDownload::class);
}
public function features(): HasMany
{
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
}
// Scopes
public function scopePublic(Builder $query): Builder
{
// Compose approved() so behavior is consistent and composable
$table = $this->getTable();
return $query->approved()->where("{$table}.is_public", true);
}
public function scopeApproved(Builder $query): Builder
{
// Respect soft deletes and mark approved content
$table = $this->getTable();
return $query->whereNull("{$table}.deleted_at")->where("{$table}.is_approved", true);
}
public function scopePublished(Builder $query): Builder
{
// Respect soft deletes and only include published items up to now
$table = $this->getTable();
return $query->whereNull("{$table}.deleted_at")
->whereNotNull("{$table}.published_at")
->where("{$table}.published_at", '<=', now());
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

View File

@@ -0,0 +1 @@
<?php

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkComment
*
* @property-read Artwork $artwork
* @property-read User $user
*/
class ArtworkComment extends Model
{
use SoftDeletes;
protected $table = 'artwork_comments';
protected $fillable = [
'artwork_id',
'user_id',
'content',
'is_approved',
];
protected $casts = [
'is_approved' => 'boolean',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkDownload
*
* @property-read Artwork $artwork
* @property-read User|null $user
*/
class ArtworkDownload extends Model
{
protected $table = 'artwork_downloads';
protected $fillable = [
'artwork_id',
'user_id',
'ip',
'user_agent',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArtworkFeature extends Model
{
protected $table = 'artwork_features';
public $timestamps = false;
protected $fillable = [
'artwork_id',
'type',
'featured_at',
];
protected $casts = [
'featured_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkStats
*
* @property-read Artwork $artwork
*/
class ArtworkStats extends Model
{
protected $table = 'artwork_stats';
protected $primaryKey = 'artwork_id';
public $incrementing = false;
protected $fillable = [
'artwork_id',
'views',
'downloads',
'favorites',
'rating_avg',
'rating_count',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkTranslation
*
* @property-read Artwork $artwork
*/
class ArtworkTranslation extends Model
{
use SoftDeletes;
protected $table = 'artwork_translations';
protected $fillable = [
'artwork_id',
'locale',
'title',
'description',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

116
app/Models/Category.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany, BelongsToMany, HasOne};
use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Model
{
use SoftDeletes;
protected $fillable = [
'content_type_id','parent_id','name','slug',
'description','image','is_active','sort_order'
];
protected $casts = ['is_active' => 'boolean'];
/**
* Ensure slug is always lowercase and valid before saving.
*/
protected static function boot()
{
parent::boot();
static::saving(function (Category $model) {
if (isset($model->slug)) {
$model->slug = strtolower($model->slug);
if (!preg_match('/^[a-z0-9-]+$/', $model->slug)) {
throw new \InvalidArgumentException('Category slug must be lowercase and contain only a-z, 0-9, and dashes.');
}
}
});
}
public function contentType(): BelongsTo
{
return $this->belongsTo(ContentType::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(Category::class, 'parent_id')
->orderBy('sort_order')->orderBy('name');
}
public function descendants(): HasMany
{
return $this->children()->with('descendants');
}
public function seo(): HasOne
{
return $this->hasOne(CategorySeo::class);
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'artwork_category');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeRoots($query)
{
return $query->whereNull('parent_id');
}
public function getFullSlugPathAttribute(): string
{
return $this->parent
? $this->parent->full_slug_path . '/' . $this->slug
: $this->slug;
}
/**
* Get the full public URL for this category (authoritative spec).
* Example: /photography/abstract/dark
*/
public function getUrlAttribute(): string
{
$contentTypeSlug = strtolower($this->contentType->slug);
$path = strtolower($this->full_slug_path);
return '/' . $contentTypeSlug . ($path ? '/' . $path : '');
}
/**
* Get the canonical URL for SEO (authoritative spec).
* Example: https://skinbase.org/photography/abstract/dark
*/
public function getCanonicalUrlAttribute(): string
{
return 'https://skinbase.org' . $this->url;
}
public function getBreadcrumbsAttribute(): array
{
return $this->parent
? array_merge($this->parent->breadcrumbs, [$this])
: [$this];
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CategorySeo extends Model
{
protected $table = 'category_seo';
protected $primaryKey = 'category_id';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'category_id','meta_title','meta_description',
'meta_keywords','canonical_url'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CategoryTranslation extends Model
{
use SoftDeletes;
public $timestamps = false;
protected $fillable = [
'category_id','locale','name','description'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ContentType extends Model
{
protected $fillable = ['name','slug','description'];
public function categories(): HasMany
{
return $this->hasMany(Category::class);
}
public function rootCategories(): HasMany
{
return $this->categories()->whereNull('parent_id');
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

50
app/Models/User.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'deleted_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Policies;
use App\Models\Artwork;
use App\Models\User;
class ArtworkPolicy
{
/**
* Global before hook: admins can do everything.
* Accepts null $user to allow public checks to continue.
*/
public function before($user, $ability)
{
if (! $user) {
return null;
}
if ($this->isAdmin($user)) {
return true;
}
return null;
}
protected function isAdmin(User $user): bool
{
if (isset($user->is_admin)) {
return (bool) $user->is_admin;
}
if (method_exists($user, 'isAdmin')) {
return (bool) $user->isAdmin();
}
if (method_exists($user, 'hasRole')) {
return (bool) $user->hasRole('admin');
}
return false;
}
/**
* Public view: only approved + public + not-deleted artworks.
*/
public function view(?User $user, Artwork $artwork): bool
{
return $artwork->is_public && $artwork->is_approved && ! $artwork->trashed();
}
/**
* Any authenticated user can create artworks.
*/
public function create(?User $user): bool
{
return (bool) $user;
}
/**
* Owner can update their own artwork.
*/
public function update(User $user, Artwork $artwork): bool
{
return $user->id === $artwork->user_id;
}
/**
* Owner can delete their own artwork (soft delete).
*/
public function delete(User $user, Artwork $artwork): bool
{
return $user->id === $artwork->user_id;
}
/**
* Restore: owner or admin can restore soft-deleted artwork.
*/
public function restore(User $user, Artwork $artwork): bool
{
return $user->id === $artwork->user_id || $this->isAdmin($user);
}
/**
* Force delete reserved for admins only.
*/
public function forceDelete(User $user, Artwork $artwork): bool
{
return $this->isAdmin($user);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use App\Models\Artwork;
use App\Policies\ArtworkPolicy;
class AuthServiceProvider extends ServiceProvider
{
/**
* The policy mappings for the application.
*
* @var array<class-string, class-string>
*/
protected $policies = [
Artwork::class => ArtworkPolicy::class,
];
/**
* Register any authentication / authorization services.
*/
public function boot(): void
{
$this->registerPolicies();
}
}

View File

@@ -0,0 +1,292 @@
<?php
namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkFeature;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Cache;
/**
* ArtworkService
*
* Business logic for retrieving artworks. Controllers should remain thin and
* delegate to this service. This service never returns JSON or accesses
* the request() helper directly.
*/
class ArtworkService
{
protected int $cacheTtl = 3600; // seconds
/**
* Fetch a single public artwork by slug.
* Applies visibility rules (public + approved + not-deleted).
*
* @param string $slug
* @return Artwork
* @throws ModelNotFoundException
*/
public function getPublicArtworkBySlug(string $slug): Artwork
{
$key = 'artwork:' . $slug;
$artwork = Cache::remember($key, $this->cacheTtl, function () use ($slug) {
$a = Artwork::where('slug', $slug)
->public()
->published()
->first();
if (! $a) {
return null;
}
// Load lightweight relations for presentation; do NOT eager-load stats here.
$a->load(['translations', 'categories']);
return $a;
});
if (! $artwork) {
$e = new ModelNotFoundException();
$e->setModel(Artwork::class, [$slug]);
throw $e;
}
return $artwork;
}
/**
* Clear artwork cache by model instance.
*/
public function clearArtworkCache(Artwork $artwork): void
{
$this->clearArtworkCacheBySlug($artwork->slug);
}
/**
* Clear artwork cache by slug.
*/
public function clearArtworkCacheBySlug(string $slug): void
{
Cache::forget('artwork:' . $slug);
}
/**
* Get artworks for a given category, applying visibility rules and cursor pagination.
* Returns a CursorPaginator so controllers/resources can render paginated feeds.
*
* @param Category $category
* @param int $perPage
* @return CursorPaginator
*/
public function getCategoryArtworks(Category $category, int $perPage = 24): CursorPaginator
{
$query = Artwork::public()->published()
->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id);
})
->orderByDesc('published_at');
// Important: do NOT eager-load artwork_stats in listings
return $query->cursorPaginate($perPage);
}
/**
* Return the latest public artworks up to $limit.
*
* @param int $limit
* @return \Illuminate\Support\Collection|EloquentCollection
*/
public function getLatestArtworks(int $limit = 10): Collection
{
return Artwork::public()->published()
->orderByDesc('published_at')
->limit($limit)
->get();
}
/**
* Browse all public, approved, published artworks with pagination.
* Uses new authoritative tables only (no legacy joins) and eager-loads
* lightweight relations needed for presentation.
*/
public function browsePublicArtworks(int $perPage = 24): CursorPaginator
{
$query = Artwork::public()
->published()
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
// Use cursor pagination for high-load browse feeds (SEO handled via canonical URLs).
return $query->cursorPaginate($perPage);
}
/**
* Browse artworks scoped to a content type slug using keyset pagination.
* Applies public + approved + published filters.
*/
public function getArtworksByContentType(string $slug, int $perPage): CursorPaginator
{
$contentType = ContentType::where('slug', strtolower($slug))->first();
if (! $contentType) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$slug]);
throw $e;
}
$query = Artwork::public()
->published()
->whereHas('categories', function ($q) use ($contentType) {
$q->where('categories.content_type_id', $contentType->id);
})
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
return $query->cursorPaginate($perPage);
}
/**
* Browse artworks for a category path (content type slug + nested category slugs).
* Uses slug-only resolution and keyset pagination.
*
* @param array<int, string> $slugs
*/
public function getArtworksByCategoryPath(array $slugs, int $perPage): CursorPaginator
{
if (empty($slugs)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class);
throw $e;
}
$parts = array_values(array_map('strtolower', $slugs));
$contentTypeSlug = array_shift($parts);
$contentType = ContentType::where('slug', $contentTypeSlug)->first();
if (! $contentType) {
$e = new ModelNotFoundException();
$e->setModel(ContentType::class, [$contentTypeSlug]);
throw $e;
}
if (empty($parts)) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, []);
throw $e;
}
// Resolve the category path from roots downward within the content type.
$current = Category::where('content_type_id', $contentType->id)
->whereNull('parent_id')
->where('slug', array_shift($parts))
->first();
if (! $current) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
foreach ($parts as $slug) {
$current = $current->children()->where('slug', $slug)->first();
if (! $current) {
$e = new ModelNotFoundException();
$e->setModel(Category::class, $slugs);
throw $e;
}
}
$query = Artwork::public()
->published()
->whereHas('categories', function ($q) use ($current) {
$q->where('categories.id', $current->id);
})
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
return $query->cursorPaginate($perPage);
}
/**
* Get featured artworks ordered by featured_at DESC, optionally filtered by type.
* Uses artwork_features table and applies public/approved/published filters.
*/
public function getFeaturedArtworks(?int $type, int $perPage = 39): LengthAwarePaginator
{
$query = Artwork::query()
->select('artworks.*')
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
->public()
->published()
->when($type !== null, function ($q) use ($type) {
$q->where('af.type', $type);
})
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
},
])
->orderByDesc('af.featured_at')
->orderByDesc('artworks.published_at');
return $query->paginate($perPage)->withQueryString();
}
/**
* Get artworks belonging to a specific user.
* If the requester is the owner, return all non-deleted artworks for that user.
* Public visitors only see public + approved + published artworks.
*
* @param int $userId
* @param bool $isOwner
* @param int $perPage
* @return CursorPaginator
*/
public function getArtworksByUser(int $userId, bool $isOwner, int $perPage = 24): CursorPaginator
{
$query = Artwork::where('user_id', $userId)
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
if (! $isOwner) {
// Apply public visibility constraints for non-owners
$query->public()->published();
} else {
// Owner: include all non-deleted items (do not force published/approved)
$query->whereNull('deleted_at');
}
return $query->cursorPaginate($perPage);
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
/**
* ArtworkStatsService
*
* Responsibilities:
* - Increment views and downloads using DB transactions
* - Optionally defer increments into Redis for async processing
* - Provide a processor to drain queued deltas (job-friendly)
*/
class ArtworkStatsService
{
protected string $redisKey = 'artwork_stats:deltas';
/**
* Increment views for an artwork.
* Set $defer=true to push to Redis for async processing when available.
*/
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'views', $by);
return;
$this->applyDelta($artworkId, ['views' => $by]);
}
/**
* Increment downloads for an artwork.
*/
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'downloads', $by);
return;
/**
* Increment views using an Artwork model. Preferred API-first signature.
*/
public function incrementViewsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementViews((int) $artwork->id, $by, $defer);
}
}
$this->applyDelta($artworkId, ['downloads' => $by]);
}
/**
* Apply a set of deltas to the artwork_stats row inside a transaction.
* This method is safe to call from jobs or synchronously.
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
public function applyDelta(int $artworkId, array $deltas): void
{
try {
DB::transaction(function () use ($artworkId, $deltas) {
// Ensure a stats row exists. Insert default zeros if missing.
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artworkId,
/**
* Increment downloads using an Artwork model. Preferred API-first signature.
*/
public function incrementDownloadsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementDownloads((int) $artwork->id, $by, $defer);
}
'views' => 0,
'downloads' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
foreach ($deltas as $column => $value) {
// Only allow known columns to avoid SQL injection
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
continue;
}
DB::table('artwork_stats')
->where('artwork_id', $artworkId)
->increment($column, (int) $value);
}
});
} catch (Throwable $e) {
Log::error('Failed to apply artwork stats delta', ['artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage()]);
}
}
/**
* Push a delta to Redis queue for async processing.
*/
protected function pushDelta(int $artworkId, string $field, int $value): void
{
$payload = json_encode([
'artwork_id' => $artworkId,
'field' => $field,
'value' => $value,
'ts' => time(),
]);
try {
Redis::rpush($this->redisKey, $payload);
} catch (Throwable $e) {
// If Redis is unavailable, fallback to immediate apply to avoid data loss
Log::warning('Redis unavailable for artwork stats; applying immediately', ['error' => $e->getMessage()]);
$this->applyDelta($artworkId, [$field => $value]);
}
}
/**
* Drain and apply queued deltas from Redis. Returns number processed.
* Designed to be invoked by a queued job or artisan command.
*/
public function processPendingFromRedis(int $max = 1000): int
{
if (! $this->redisAvailable()) {
return 0;
}
$processed = 0;
try {
while ($processed < $max) {
$item = Redis::lpop($this->redisKey);
if (! $item) {
break;
}
$decoded = json_decode($item, true);
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
continue;
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
$processed++;
}
} catch (Throwable $e) {
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
}
return $processed;
}
protected function redisAvailable(): bool
{
try {
// Redis facade may throw if not configured
$pong = Redis::connection()->ping();
return (bool) $pong;
} catch (Throwable $e) {
return false;
}
}
}

View File

@@ -0,0 +1,655 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Log;
/**
* @deprecated LegacyService contains helpers to render legacy pages and should be
* migrated to new services. Keep in place until legacy controllers/views
* are refactored. Instantiating the service will emit a deprecation log.
*/
class LegacyService
{
public function __construct()
{
Log::warning('App\Services\LegacyService is deprecated. Please migrate callers to modern services.');
}
public function featured(): array
{
$featured = null;
$memberFeatured = null;
try {
$featured = DB::connection('legacy')->table('featured_works as fw')
->leftJoin('wallz as w', 'fw.artwork_id', '=', 'w.id')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->select('w.id', 'w.name', 'w.picture', 'u.uname', 'fw.post_date')
->orderByDesc('fw.post_date')
->first();
$memberFeatured = DB::connection('legacy')->table('users_opinions as o')
->leftJoin('wallz as w', 'o.artwork_id', '=', 'w.id')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->select(DB::raw('COUNT(*) AS votes'), 'w.id', 'w.name', 'w.picture', 'u.uname')
->whereRaw('o.post_date > SUBDATE(CURRENT_DATE(), INTERVAL 30 DAY)')
->where('o.score', 4)
->groupBy('o.artwork_id', 'w.id', 'w.name', 'w.picture', 'u.uname')
->orderByDesc('votes')
->limit(1)
->first();
} catch (\Throwable $e) {
// fail soft
}
if (!$featured) {
$featured = (object) [
'id' => 0,
'name' => 'Featured Artwork',
'picture' => '/gfx/sb_join.jpg',
'uname' => 'Skinbase',
];
}
if (!$memberFeatured) {
$memberFeatured = (object) [
'id' => 0,
'name' => 'Members Pick',
'picture' => '/gfx/sb_join.jpg',
'uname' => 'Skinbase',
'votes' => 0,
];
}
return [$featured, $memberFeatured];
}
public function latestUploads(): array
{
$uploads = [];
$cachePath = base_path('oldSite/www/cache/latest_uploads.json');
if (File::exists($cachePath)) {
$json = File::get($cachePath);
$uploads = json_decode($json, true) ?: [];
}
if (empty($uploads)) {
try {
$uploads = DB::connection('legacy')->table('wallz as w')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
->where('w.approved', 1)
->orderByDesc('w.datum')
->limit(20)
->get()
->map(function ($row) {
return [
'id' => $row->id,
'name' => $row->name,
'picture' => $row->picture,
'uname' => $row->uname,
'category_name' => $row->category_name ?? '',
];
})
->toArray();
} catch (\Throwable $e) {
$uploads = [];
}
}
if (empty($uploads)) {
$uploads = [
[
'id' => 1,
'name' => 'Sample Artwork',
'picture' => 'gfx/sb_join.jpg',
'uname' => 'Skinbase',
'category_name' => 'Photography',
],
];
}
return $uploads;
}
public function forumNews(): array
{
try {
return DB::connection('legacy')->table('forum_topics as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->select(
't1.topic_id',
't1.topic',
't1.views',
't1.post_date',
't1.preview',
't2.uname'
)
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.post_date')
->limit(8)
->get()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
public function ourNews(): array
{
try {
return DB::connection('legacy')->table('news as t1')
->join('news_categories as t2', 't1.category_id', '=', 't2.category_id')
->join('users as t3', 't1.user_id', '=', 't3.user_id')
->select(
't1.news_id',
't1.headline',
't1.picture',
't1.preview',
't1.create_date',
't1.views',
't2.category_name',
't3.uname',
DB::raw('(SELECT COUNT(*) FROM news_comment WHERE news_id = t1.news_id) AS num_comments')
)
->orderByDesc('t1.create_date')
->limit(5)
->get()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
public function latestForumActivity(): array
{
try {
return DB::connection('legacy')->table('forum_topics as t1')
->select(
't1.topic_id',
't1.topic',
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
)
->where('t1.root_id', '<>', 0)
->where('t1.root_id', '<>', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.last_update')
->orderByDesc('t1.post_date')
->limit(10)
->get()
->toArray();
} catch (\Throwable $e) {
return [];
}
}
public function browseGallery(int $perPage = 50)
{
try {
return DB::connection('legacy')->table('wallz as w')
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->select('w.id', 'w.name', 'w.picture', 'w.category', 'w.datum', 'c.category_name', 'u.uname')
->where('w.approved', 1)
->where('w.public', 'Y')
->orderByDesc('w.datum')
->paginate($perPage)
->withQueryString();
} catch (\Throwable $e) {
return null;
}
}
public function categoryPage(string $group, ?string $slug = null, ?int $id = null)
{
$group = \Illuminate\Support\Str::title($group);
$defaults = [
'Skins' => 1,
'Wallpapers' => 2,
'Photography' => 3,
'Other' => 4,
];
if (!$id && $slug && ctype_digit($slug)) {
$id = (int) $slug;
}
$id = $id ?: ($defaults[$group] ?? null);
if (!$id || $id < 1) {
return null;
}
try {
$category = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name', 'description', 'rootid', 'section_id')
->where('category_id', $id)
->first();
} catch (\Throwable $e) {
$category = null;
}
if (! $category) {
return null;
}
$perPage = 40;
try {
$base = DB::connection('legacy')->table('wallz as t1')
->select('t1.id', 't1.name', 't1.picture', 't3.uname', 't1.category', 't2.category_name')
->join('artworks_categories as t2', 't1.category', '=', 't2.category_id')
->leftJoin('users as t3', 't1.user_id', '=', 't3.user_id')
->where('t1.approved', 1)
->where(function ($q) use ($id, $category) {
$q->where('t1.category', (int) $id);
if ($category->rootid > 0) {
$q->orWhere('t1.rootid', (int) $id);
}
})
->orderByDesc('t1.datum');
$artworks = $base->paginate($perPage)->withQueryString();
if ($artworks && method_exists($artworks, 'getCollection')) {
$artworks->getCollection()->transform(function ($row) {
$row->gid_num = ((int) ($row->category ?? 0) % 5) * 5;
if (!empty($row->picture)) {
$ext = self::fileExtension($row->picture);
$encoded = self::encode($row->id);
$row->ext = $ext;
$row->encoded = $encoded;
// Prefer new files.skinbase.org when possible
try {
$art = \App\Models\Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$row->thumb_url = $present['url'];
$row->thumb_srcset = $present['srcset'];
} catch (\Throwable $e) {
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
$row->thumb_url = $present['url'];
$row->thumb_srcset = $present['srcset'];
}
} else {
$row->ext = null;
$row->encoded = null;
$row->thumb_url = '/gfx/sb_join.jpg';
$row->thumb_srcset = null;
}
return $row;
});
}
} catch (\Throwable $e) {
$artworks = null;
}
try {
$subcategories = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name')
->where('rootid', $id)
->orderBy('category_name')
->get();
if ($subcategories->isEmpty() && $category->rootid) {
$subcategories = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name')
->where('rootid', $category->rootid)
->orderBy('category_name')
->get();
}
if ($subcategories->isEmpty()) {
$subcategories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name')
->where('rootid', $id)
->orderBy('category_name')
->get();
}
} catch (\Throwable $e) {
try {
$subcategories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name')
->where('rootid', $id)
->orderBy('category_name')
->get();
} catch (\Throwable $e2) {
$subcategories = collect();
}
}
$page_title = $group;
$page_meta_description = $group . ' artworks on Skinbase';
$page_meta_keywords = strtolower($group) . ', skinbase, artworks, wallpapers, skins';
return [
'group' => $group,
'category' => $category,
'artworks' => $artworks,
'subcategories' => $subcategories,
'page_title' => $page_title,
'page_meta_description' => $page_meta_description,
'page_meta_keywords' => $page_meta_keywords,
];
}
public function browseCategories()
{
try {
$categories = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name', 'description')
->where('section_id', 0)
->where('rootid', 0)
->orderBy('category_id')
->get();
if ($categories->isEmpty()) {
$categories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name', 'description')
->where('section_id', 0)
->where('rootid', 0)
->orderBy('category_id')
->get();
}
} catch (\Throwable $e) {
try {
$categories = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name', 'description')
->where('section_id', 0)
->where('rootid', 0)
->orderBy('category_id')
->get();
} catch (\Throwable $e2) {
$categories = collect();
}
}
$subgroups = collect();
if ($categories->isNotEmpty()) {
$ids = $categories->pluck('category_id')->unique()->values()->all();
try {
$subs = DB::connection('legacy')->table('artworks_categories')
->select('category_id', 'category_name', 'picture', 'section_id')
->whereIn('section_id', $ids)
->orderBy('category_name')
->get();
if ($subs->isEmpty()) {
$subs = DB::connection('legacy')->table('skupine')
->select('category_id', 'category_name', 'picture', 'section_id')
->whereIn('section_id', $ids)
->orderBy('category_name')
->get();
}
$subgroups = $subs->groupBy('section_id');
} catch (\Throwable $e) {
$subgroups = collect();
}
}
return [
'categories' => $categories,
'subgroups' => $subgroups,
'page_title' => 'Browse Categories',
'page_meta_description' => 'Browse categories across Photography, Wallpapers, Skins and more on Skinbase.',
'page_meta_keywords' => 'categories, photography, wallpapers, skins, browse',
];
}
public function forumIndex()
{
try {
$topics = DB::connection('legacy')->table('forum_topics as t')
->select(
't.topic_id',
't.topic',
't.discuss',
't.last_update',
't.privilege',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
)
->where('t.root_id', 0)
->where('t.privilege', '<', 4)
->orderByDesc('t.last_update')
->limit(100)
->get();
} catch (\Throwable $e) {
$topics = collect();
}
return [
'topics' => $topics,
'page_title' => 'Forum',
'page_meta_description' => 'Skinbase forum threads.',
'page_meta_keywords' => 'forum, discussions, topics, skinbase',
];
}
public function forumTopic(int $topic_id, int $page = 1)
{
try {
$topic = DB::connection('legacy')->table('forum_topics')->where('topic_id', $topic_id)->first();
} catch (\Throwable $e) {
$topic = null;
}
if (! $topic) {
return null;
}
try {
$subtopics = DB::connection('legacy')->table('forum_topics as t')
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
->select(
't.topic_id',
't.topic',
't.discuss',
't.post_date',
't.last_update',
'u.uname',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
)
->where('t.root_id', $topic->topic_id)
->orderByDesc('t.last_update')
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$subtopics = null;
}
if ($subtopics && $subtopics->total() > 0) {
return [
'type' => 'subtopics',
'topic' => $topic,
'subtopics' => $subtopics,
'page_title' => $topic->topic,
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
'page_meta_keywords' => 'forum, topic, skinbase',
];
}
$sort = strtolower(request()->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
try {
$posts = DB::connection('legacy')->table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.topic_id', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$posts = null;
}
if (! $posts || $posts->total() === 0) {
try {
$posts = DB::connection('legacy')->table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.tid', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$posts = null;
}
}
// Ensure $posts is always a LengthAwarePaginator so views can iterate and render pagination safely
if (! $posts) {
$currentPage = max(1, (int) request()->query('page', $page));
$items = collect();
$posts = new LengthAwarePaginator($items, 0, 50, $currentPage, [
'path' => Paginator::resolveCurrentPath(),
]);
}
return [
'type' => 'posts',
'topic' => $topic,
'posts' => $posts,
'page_title' => $topic->topic,
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
'page_meta_keywords' => 'forum, topic, skinbase',
];
}
/**
* Fetch a single artwork by id with author and category
* Returns null on failure.
*/
public function getArtwork(int $id)
{
try {
$row = DB::connection('legacy')->table('wallz as w')
->leftJoin('users as u', 'w.user_id', '=', 'u.user_id')
->leftJoin('artworks_categories as c', 'w.category', '=', 'c.category_id')
->select('w.*', 'u.uname', 'c.category_name')
->where('w.id', $id)
->first();
} catch (\Throwable $e) {
$row = null;
}
if (! $row) {
return null;
}
// compute thumbnail/zoom paths similar to legacy code
$nid = (int) ($row->id / 100);
$nid_new = (int) ($row->id / 1000);
$encoded = self::encode($row->id);
$ext = self::fileExtension($row->picture ?? 'jpg');
$appUrl = rtrim(config('app.url', ''), '/');
$shot_name = $appUrl . '/files/archive/shots/' . $nid . '/' . ($row->picture ?? '');
$zoom_name = $appUrl . '/files/archive/zoom/' . $nid . '/' . ($row->picture ?? '');
$thumb_600 = $appUrl . '/files/thumb/' . $nid_new . '/600_' . $row->id . '.jpg';
// Prefer new CDN when possible
try {
$art = \App\Models\Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$thumb_file = $present['url'];
$thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null);
} catch (\Throwable $e) {
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
$thumb_file = $present['url'];
$thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null);
}
// additional stats (best-effort)
try {
$num_downloads = DB::connection('legacy')->table('artworks_downloads')
->where('date', DB::raw('CURRENT_DATE'))
->where('artwork_id', $row->id)
->count();
} catch (\Throwable $e) {
$num_downloads = 0;
}
try {
$monthly_downloads = DB::connection('legacy')->table('monthly_downloads')
->where('fname', $row->id)
->count();
} catch (\Throwable $e) {
$monthly_downloads = 0;
}
try {
$num_comments = DB::connection('legacy')->table('artworks_comments')
->where('name', $row->id)
->where('author', '<>', '')
->count();
} catch (\Throwable $e) {
$num_comments = 0;
}
try {
$num_favourites = DB::connection('legacy')->table('favourites')
->where('artwork_id', $row->id)
->count();
} catch (\Throwable $e) {
$num_favourites = 0;
}
try {
$featured = DB::connection('legacy')->table('featured_works')
->where('rootid', $row->rootid ?? 0)
->where('artwork_id', $row->id)
->orderByDesc('type')
->select('type', 'post_date')
->first();
$featured_type = $featured->type ?? 0;
$featured_date = $featured->post_date ?? null;
} catch (\Throwable $e) {
$featured_type = 0;
$featured_date = null;
}
$page_title = $row->name ?? 'Artwork';
$page_meta_description = strip_tags($row->description ?? ($row->preview ?? ''));
$page_meta_keywords = trim(($row->category_name ?? '') . ', artwork');
return [
'artwork' => $row,
'thumb_file' => $thumb_file,
'thumb_file_300' => $thumb_file_300,
'thumb_600' => $thumb_600,
'shot_name' => $shot_name,
'zoom_name' => $zoom_name,
'num_downloads' => $num_downloads,
'monthly_downloads' => $monthly_downloads,
'num_comments' => $num_comments,
'num_favourites' => $num_favourites,
'featured_type' => $featured_type,
'featured_date' => $featured_date,
'page_title' => $page_title,
'page_meta_description' => $page_meta_description,
'page_meta_keywords' => $page_meta_keywords,
];
}
public static function encode($val, $base = 62, $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') {
$str = '';
if ($val < 0) return $str;
do {
$i = $val % $base;
$str = $chars[$i] . $str;
$val = ($val - $i) / $base;
} while ($val > 0);
return $str;
}
private static function fileExtension($filename) {
$parts = pathinfo($filename);
return $parts['extension'] ?? 'jpg';
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Services\ThumbnailService;
class ThumbnailPresenter
{
/**
* Present thumbnail data for an item which may be a model or an array.
* Returns ['id' => int|null, 'title' => string, 'url' => string, 'srcset' => string|null]
*/
public static function present($item, string $size = 'md'): array
{
$uext = 'jpg';
$isEloquent = $item instanceof \Illuminate\Database\Eloquent\Model;
$id = null;
$title = '';
if ($isEloquent) {
$id = $item->id ?? null;
$title = $item->name ?? '';
$url = $item->thumb_url ?? $item->thumb ?? '';
$srcset = $item->thumb_srcset ?? null;
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset];
}
// If it's an object but not an Eloquent model (e.g. stdClass row), cast to array
if (is_object($item)) {
$item = (array) $item;
}
$id = $item['id'] ?? null;
$title = $item['name'] ?? '';
// If array contains direct hash/thumb_ext, use CDN fromHash
$hash = $item['hash'] ?? null;
$thumbExt = $item['thumb_ext'] ?? ($item['ext'] ?? $uext);
if (!empty($hash) && !empty($thumbExt)) {
$url = ThumbnailService::fromHash($hash, $thumbExt, $size) ?: ThumbnailService::url(null, $id, $thumbExt, 6);
$srcset = ThumbnailService::srcsetFromHash($hash, $thumbExt);
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset];
}
// Fallback: ask ThumbnailService to resolve by id or file path
$url = ThumbnailService::url(null, $id, $uext, 6);
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => null];
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Storage;
class ThumbnailService
{
protected const CDN_HOST = 'http://files.skinbase.org';
protected const VALID_SIZES = ['sm','md','lg','xl'];
protected const THUMB_SIZES = [
'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'],
'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'],
'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'],
'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'],
];
/**
* Build a thumbnail URL from a filePath/hash/id and ext.
* Accepts either a direct hash string in $filePath, or an $id + $ext pair.
* Legacy size codes (4 -> sm, others -> md) are supported.
*/
public static function url(?string $filePath, ?int $id = null, ?string $ext = null, $size = 6): string
{
// If $filePath seems to be a content hash and $ext is provided, build directly
if (!empty($filePath) && !empty($ext) && preg_match('/^[0-9a-f]{16,128}$/i', $filePath)) {
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md');
return self::fromHash($filePath, $ext, $sizeKey) ?: '';
}
// Resolve by id when provided
if ($id !== null) {
try {
$artClass = '\\App\\Models\\Artwork';
if (class_exists($artClass)) {
$art = $artClass::where('id', $id)->orWhere('legacy_id', $id)->first();
if ($art) {
$hash = $art->hash ?? null;
$extToUse = $ext ?? ($art->thumb_ext ?? null);
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md');
if (!empty($hash) && !empty($extToUse)) {
return self::fromHash($hash, $extToUse, $sizeKey) ?: '';
}
}
}
} catch (\Throwable $e) {
// fallthrough to storage/filePath fallback
}
}
// Fallback to Storage::url or return provided path
if (!empty($filePath)) {
try {
return Storage::url($filePath);
} catch (\Throwable $e) {
return $filePath;
}
}
return '';
}
/**
* Build CDN URL from hash and extension.
*/
public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string
{
if (empty($hash) || empty($ext)) return null;
$sizeKey = in_array($sizeKey, self::VALID_SIZES) ? $sizeKey : 'md';
$h = $hash;
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
return sprintf('%s/%s/%s/%s/%s.%s', rtrim(self::CDN_HOST, '/'), $sizeKey, $h1, $h2, $h, $ext);
}
/**
* Build srcset using sm and md sizes for legacy layouts.
*/
public static function srcsetFromHash(?string $hash, ?string $ext): ?string
{
$a = self::fromHash($hash, $ext, 'sm');
$b = self::fromHash($hash, $ext, 'md');
if (!$a || !$b) return null;
return $a . ' 320w, ' . $b . ' 600w';
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.guest');
}
}

18
artisan Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

50427
artworks_hash_skinbase.csv Normal file

File diff suppressed because it is too large Load Diff

19
bootstrap/app.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

6
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
];

92
composer.json Normal file
View File

@@ -0,0 +1,92 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^2.0",
"laravel/breeze": "*",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.3",
"pestphp/pest-plugin-laravel": "^4.0"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"npm run dev\" --names='server,queue,vite'"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

9607
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

115
config/auth.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

Some files were not shown because too many files have changed in this diff Show More