Current state
This commit is contained in:
310
.copilot/api-first.md
Normal file
310
.copilot/api-first.md
Normal 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
370
.copilot/artworks.md
Normal 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
259
.copilot/categories.md
Normal 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
293
.copilot/inctructions.md
Normal 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
|
||||
816
.copilot/legacy_users_migration.md
Normal file
816
.copilot/legacy_users_migration.md
Normal 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 we’re 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”. We’ll 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 Laravel’s 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 Laravel’s standard password reset email flow
|
||||
|
||||
### 9.3 UX recommendation
|
||||
|
||||
* When migrating, send optional mass email campaign:
|
||||
|
||||
* “SkinBase upgraded – set your new password”
|
||||
* But don’t 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.1–5.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
19
.copilot/prompts.md
Normal 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
|
||||
240
.copilot/skinbase-category-links.md
Normal file
240
.copilot/skinbase-category-links.md
Normal 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
309
.copilot/thumbnails.md
Normal 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
18
.editorconfig
Normal 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
73
.env.example
Normal 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
11
.gitattributes
vendored
Normal 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
24
.gitignore
vendored
Normal 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
59
README.md
Normal 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
35
app/Banner.php
Normal 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
100
app/Chat.php
Normal 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 . '><';
|
||||
$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>> ';
|
||||
$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>';
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/ImportArtworkHashes.php
Normal file
92
app/Console/Commands/ImportArtworkHashes.php
Normal 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;
|
||||
}
|
||||
}
|
||||
193
app/Console/Commands/ImportCategories.php
Normal file
193
app/Console/Commands/ImportCategories.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
291
app/Console/Commands/ImportLegacyArtworks.php
Normal file
291
app/Console/Commands/ImportLegacyArtworks.php
Normal 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;
|
||||
}
|
||||
}
|
||||
203
app/Console/Commands/ImportLegacyUsers.php
Normal file
203
app/Console/Commands/ImportLegacyUsers.php
Normal 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';
|
||||
}
|
||||
}
|
||||
152
app/Console/Commands/MigrateFeaturedWorks.php
Normal file
152
app/Console/Commands/MigrateFeaturedWorks.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
app/Console/Commands/ResetAllUserPasswords.php
Normal file
60
app/Console/Commands/ResetAllUserPasswords.php
Normal 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
41
app/Console/Kernel.php
Normal 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
78
app/Helpers/Thumb.php
Normal 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;
|
||||
}
|
||||
}
|
||||
46
app/Http/Controllers/Api/ArtworkController.php
Normal file
46
app/Http/Controllers/Api/ArtworkController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
78
app/Http/Controllers/Api/BrowseController.php
Normal file
78
app/Http/Controllers/Api/BrowseController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
64
app/Http/Controllers/ArtworkController.php
Normal file
64
app/Http/Controllers/ArtworkController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
62
app/Http/Controllers/Auth/NewPasswordController.php
Normal 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)]);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
29
app/Http/Controllers/Auth/PasswordController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
44
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal 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)]);
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
50
app/Http/Controllers/Auth/RegisteredUserController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/BrowseCategoriesController.php
Normal file
34
app/Http/Controllers/BrowseCategoriesController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
81
app/Http/Controllers/CategoryPageController.php
Normal file
81
app/Http/Controllers/CategoryPageController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
62
app/Http/Controllers/Legacy/ArtController.php
Normal file
62
app/Http/Controllers/Legacy/ArtController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/Legacy/AvatarController.php
Normal file
67
app/Http/Controllers/Legacy/AvatarController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/Legacy/BrowseController.php
Normal file
85
app/Http/Controllers/Legacy/BrowseController.php
Normal 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 ?? '#',
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Http/Controllers/Legacy/CategoryController.php
Normal file
108
app/Http/Controllers/Legacy/CategoryController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/Legacy/ChatController.php
Normal file
48
app/Http/Controllers/Legacy/ChatController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
98
app/Http/Controllers/Legacy/DailyUploadsController.php
Normal file
98
app/Http/Controllers/Legacy/DailyUploadsController.php
Normal 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',
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Legacy/FeaturedArtworksController.php
Normal file
63
app/Http/Controllers/Legacy/FeaturedArtworksController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Legacy/ForumController.php
Normal file
38
app/Http/Controllers/Legacy/ForumController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Legacy/HomeController.php
Normal file
58
app/Http/Controllers/Legacy/HomeController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Legacy/InterviewController.php
Normal file
106
app/Http/Controllers/Legacy/InterviewController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
app/Http/Controllers/Legacy/InterviewsController.php
Normal file
28
app/Http/Controllers/Legacy/InterviewsController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
56
app/Http/Controllers/Legacy/LatestCommentsController.php
Normal file
56
app/Http/Controllers/Legacy/LatestCommentsController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Legacy/LatestController.php
Normal file
51
app/Http/Controllers/Legacy/LatestController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Legacy/MembersController.php
Normal file
51
app/Http/Controllers/Legacy/MembersController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Legacy/NewsController.php
Normal file
44
app/Http/Controllers/Legacy/NewsController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Legacy/ProfileController.php
Normal file
75
app/Http/Controllers/Legacy/ProfileController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/Legacy/TodayDownloadsController.php
Normal file
68
app/Http/Controllers/Legacy/TodayDownloadsController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Legacy/TodayInHistoryController.php
Normal file
54
app/Http/Controllers/Legacy/TodayInHistoryController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
129
app/Http/Controllers/Legacy/TopAuthorsController.php
Normal file
129
app/Http/Controllers/Legacy/TopAuthorsController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/Legacy/TopFavouritesController.php
Normal file
57
app/Http/Controllers/Legacy/TopFavouritesController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
564
app/Http/Controllers/LegacyController.php
Normal file
564
app/Http/Controllers/LegacyController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/ManageController.php
Normal file
114
app/Http/Controllers/ManageController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/ProfileController.php
Normal file
61
app/Http/Controllers/ProfileController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Web/ArtworkController.php
Normal file
58
app/Http/Controllers/Web/ArtworkController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/ArtworkIndexRequest.php
Normal file
21
app/Http/Requests/ArtworkIndexRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
85
app/Http/Requests/Auth/LoginRequest.php
Normal file
85
app/Http/Requests/Auth/LoginRequest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/ProfileUpdateRequest.php
Normal file
30
app/Http/Requests/ProfileUpdateRequest.php
Normal 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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
81
app/Http/Resources/ArtworkListResource.php
Normal file
81
app/Http/Resources/ArtworkListResource.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Http/Resources/ArtworkResource.php
Normal file
64
app/Http/Resources/ArtworkResource.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
73
app/Jobs/IncrementArtworkView.php
Normal file
73
app/Jobs/IncrementArtworkView.php
Normal 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
178
app/Models/Artwork.php
Normal 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';
|
||||
}
|
||||
}
|
||||
1
app/Models/ArtworkCategory.php
Normal file
1
app/Models/ArtworkCategory.php
Normal file
@@ -0,0 +1 @@
|
||||
<?php
|
||||
40
app/Models/ArtworkComment.php
Normal file
40
app/Models/ArtworkComment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
33
app/Models/ArtworkDownload.php
Normal file
33
app/Models/ArtworkDownload.php
Normal 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);
|
||||
}
|
||||
}
|
||||
27
app/Models/ArtworkFeature.php
Normal file
27
app/Models/ArtworkFeature.php
Normal 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);
|
||||
}
|
||||
}
|
||||
33
app/Models/ArtworkStats.php
Normal file
33
app/Models/ArtworkStats.php
Normal 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');
|
||||
}
|
||||
}
|
||||
30
app/Models/ArtworkTranslation.php
Normal file
30
app/Models/ArtworkTranslation.php
Normal 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
116
app/Models/Category.php
Normal 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';
|
||||
}
|
||||
}
|
||||
24
app/Models/CategorySeo.php
Normal file
24
app/Models/CategorySeo.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Models/CategoryTranslation.php
Normal file
23
app/Models/CategoryTranslation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
app/Models/ContentType.php
Normal file
26
app/Models/ContentType.php
Normal 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
50
app/Models/User.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
90
app/Policies/ArtworkPolicy.php
Normal file
90
app/Policies/ArtworkPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
27
app/Providers/AuthServiceProvider.php
Normal file
27
app/Providers/AuthServiceProvider.php
Normal 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();
|
||||
}
|
||||
}
|
||||
292
app/Services/ArtworkService.php
Normal file
292
app/Services/ArtworkService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
160
app/Services/ArtworkStatsService.php
Normal file
160
app/Services/ArtworkStatsService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
655
app/Services/LegacyService.php
Normal file
655
app/Services/LegacyService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
49
app/Services/ThumbnailPresenter.php
Normal file
49
app/Services/ThumbnailPresenter.php
Normal 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];
|
||||
}
|
||||
}
|
||||
87
app/Services/ThumbnailService.php
Normal file
87
app/Services/ThumbnailService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
17
app/View/Components/AppLayout.php
Normal file
17
app/View/Components/AppLayout.php
Normal 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');
|
||||
}
|
||||
}
|
||||
17
app/View/Components/GuestLayout.php
Normal file
17
app/View/Components/GuestLayout.php
Normal 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
18
artisan
Normal 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
50427
artworks_hash_skinbase.csv
Normal file
File diff suppressed because it is too large
Load Diff
19
bootstrap/app.php
Normal file
19
bootstrap/app.php
Normal 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
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
6
bootstrap/providers.php
Normal file
6
bootstrap/providers.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
];
|
||||
92
composer.json
Normal file
92
composer.json
Normal 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
9607
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal 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
115
config/auth.php
Normal 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
Reference in New Issue
Block a user