# Member Resource Center – MU‑Plugin Build Spec (Local Media + WC Memberships)

Members‑only resource hub built as a **MU‑plugin** for Bedrock/Sage. Uses **WooCommerce Memberships** for access, **local media uploads** via the WP Media Library, and a protected download controller (no S3/Spaces).

> Scope: Business logic in a plugin, independent of theme. Files uploaded through the standard Media modal are stored under a **private subdirectory** and served via a gated endpoint.

---

## Goals
- Members‑only area to access resources/documents
- Manage resources independent of Pages/Posts
- Use WP Media Library for uploads (with a protected storage path)
- Gate access by **login** AND **active Woo Membership plan(s)**

---

## Project Layout (Bedrock)
```
app/mu-plugins/member-resource-center/
├─ member-resource-center.php              # bootstrap
├─ src/
│  ├─ Plugin.php                           # hooks, boot
│  ├─ PostType.php                         # CPT mrc_resource + caps
│  ├─ Taxonomies.php                       # mrc_topic, mrc_audience
│  ├─ Capabilities.php                     # role caps
│  ├─ Access.php                           # wc memberships checks
│  ├─ Admin/
│  │  ├─ ListTable.php                     # custom admin list (optional)
│  │  └─ Columns.php                       # admin columns
│  ├─ Uploads/
│  │  ├─ PrivateUploads.php                # upload_dir filter scoping
│  │  └─ Mover.php                         # safe moves for existing files
│  ├─ Api/
│  │  ├─ Rest.php                          # /wp-json/mrc/v1
│  │  └─ Controllers/
│  │     ├─ ResourcesController.php        # list/search
│  │     └─ DownloadController.php         # gated downloads (token)
│  ├─ Tokens/
│  │  └─ TokenRepo.php                     # one-time tokens (DB)
│  ├─ Views/
│  │  └─ shortcode-grid.php                # frontend grid (PHP)
│  └─ Utils/
│     └─ Sanitizers.php
├─ assets/admin.css
├─ readme.md
└─ .htaccess                               # deny direct access to /mrc-private
```

---

## Dependencies
- WooCommerce + WooCommerce **Memberships** (already in composer)
- ACF Pro (field groups)
- No new composer packages required.

---

## Data Model
**CPT:** `mrc_resource` (menu: Member Resources)
- `public` false; `show_ui` true; custom caps
- Supports: `title`, `thumbnail`

**Taxonomies:**
- `mrc_topic` (hierarchical)
- `mrc_audience` (non‑hierarchical)

**ACF Field Group: Resource Fields**
- `resource_type` (select): `file` | `external_link` | `page_section`
- `attachment_id` (file) – standard Media modal
- `external_url` (url)
- `summary` (textarea)
- `requires_membership_plans` (Relationship → wc_memberships_plan)
- `expires_at` (date/time, optional)
- `version` (text), `changelog` (textarea, optional)

> Note: When `resource_type = file`, we will save the file to a **private subdirectory** under uploads and store the attachment ID + absolute path meta.

---

## Protected Storage Strategy (Local Only)
- Private path: `wp-content/uploads/mrc-private/` (inside WP uploads root but not web‑reachable)
- Add `.htaccess` in `mrc-private`:
  ```
  Order allow,deny
  Deny from all
  ```
- All file access must go through `DownloadController`.

### Upload Flow
- On `media_upload` when invoked **from the MRC screen** (or when saving a `mrc_resource`), temporarily apply `upload_dir` filter to route files into `mrc-private`.
- Store `_mrc_private_path` (absolute path) and `_mrc_private_rel` (relative path) on the attachment + `mrc_resource` post.
- Render admin preview with filename/size; do **not** show public URL.

### Backfill/Migration (optional)
- If editors attach an existing public file, offer a “Move to Private Storage” button → physically move file into `mrc-private`, update attachment metadata, regenerate sizes if needed, and replace references.

---

## Access Control
- **Login required**: `is_user_logged_in()`
- **Membership required**: user must be active in ANY of the plans linked in `requires_membership_plans`.
- Helper:
  ```php
  function mrc_user_in_any_plan(int $user_id, array $plan_ids): bool
  ```
- If no plans are attached, treat as **members‑only** (logged in + any active membership) or tighten via a global setting.

---

## REST API
Base: `/wp-json/mrc/v1`

### GET `/resources`
- Auth: logged in
- Query params: `topic`, `audience`, `search`, `page`, `per_page`
- Filters out expired items; checks membership per item
- Returns: `{ items: [{ id, title, summary, type, topics, audiences }], total }`

### POST `/download/{id}`
- Auth: logged in
- Body: none
- Verifies membership access; if `external_link` returns `{ url }` directly
- If `file`, issues a **one‑time token** and returns `{ url: "/mrc/download?token=..." }`

### GET `/verify`
- Health check for frontend; returns `{ logged_in, plans: [...] }`

---

## Tokenized Downloads
**Table:** `{prefix}mrc_download_tokens`
- `id` BIGINT PK
- `token` VARCHAR(64) UNIQUE INDEX
- `user_id` BIGINT INDEX
- `resource_id` BIGINT INDEX
- `expires_at` DATETIME INDEX (e.g., now + 10 minutes)
- `ip`, `ua` (optional logging)
- `consumed_at` DATETIME NULL

**Flow:**
1) Frontend calls `POST /download/{id}` → server creates token
2) Frontend redirects browser to `/mrc/download?token=...`
3) `template_redirect` hook catches `/mrc/download` and, if token valid/unconsumed & membership OK, streams the file with headers:
   - `Content-Type: application/octet-stream`
   - `Content-Disposition: attachment; filename="..."`
   - `Content-Length: ...`
   - `X-Accel-Buffering: no` (avoid buffering if behind Nginx proxy)
4) Mark token consumed (single-use)

> If server supports **X-Sendfile**/**X-Accel-Redirect**, prefer it for performance; otherwise use `readfile()` in chunks.

---

## Frontend Integration
**Shortcode:** `[mrc_resources]`
- Renders a small PHP view (`Views/shortcode-grid.php`) or Alpine/React mount
- UI: filters (topic/audience/search), list of resources, “Download” button per item
- Button → `POST /mrc/v1/download/{id}` → redirect to returned URL

**Members Page**
- Create a WP Page “Members” and add `[mrc_resources]`
- Use Sage for styling if present; plugin remains theme‑agnostic

---

## Admin UX
- CPT screen with columns: Topic(s), Audience(s), Type, Plans, Expires, Updated
- Quick filters for Topic/Audience
- Row action: **Copy Member Link** (points to POST flow, not a raw file URL)
- Metabox (ACF) enforces required fields depending on `resource_type`

---

## Security Notes
- Admin routes gated by `manage_options` or `manage_woocommerce`
- REST routes gated by login; item‑level membership checks
- `.htaccess` deny on private dir; file URLs never exposed
- One‑time token TTL (default 10 min); IP/UA optional audit
- Nonces on admin actions; strict sanitization of URLs/text

---

## Minimal Bootstrap (outline)
```php
/**
 * Plugin Name: Member Resource Center
 * Description: Members-only resources with Woo Memberships gating and private downloads.
 */

spl_autoload_register(function($class){
  if (strpos($class, 'MRC\\') !== 0) return;
  $path = __DIR__ . '/src/' . str_replace('\\','/', substr($class, 4)) . '.php';
  if (file_exists($path)) require $path;
});

add_action('plugins_loaded', ['\\MRC\\Plugin','boot']);
```

---

## Key Hooks & Snippets
### Register CPT & Taxonomies
```php
register_post_type('mrc_resource', [
  'label' => 'Member Resources',
  'public' => false,
  'show_ui' => true,
  'show_in_menu' => true,
  'supports' => ['title','thumbnail'],
  'capability_type' => ['mrc_resource','mrc_resources'],
  'map_meta_cap' => true,
]);

register_taxonomy('mrc_topic','mrc_resource',[ 'hierarchical'=>true, 'show_ui'=>true ]);
register_taxonomy('mrc_audience','mrc_resource',[ 'hierarchical'=>false, 'show_ui'=>true ]);
```

### Upload Dir Filter (scope to MRC)
```php
add_filter('upload_dir', function($dirs){
  if (!MRC\Uploads\PrivateUploads::should_scope()) return $dirs;
  $sub = '/mrc-private' . (empty($dirs['subdir']) ? '' : $dirs['subdir']);
  $basedir = trailingslashit($dirs['basedir']) . 'mrc-private';
  $baseurl = trailingslashit($dirs['baseurl']) . 'mrc-private'; // blocked by .htaccess
  return [
    'path'   => $basedir . $dirs['subdir'],
    'url'    => $baseurl . $dirs['subdir'],
    'subdir' => $dirs['subdir'],
    'basedir'=> $basedir,
    'baseurl'=> $baseurl,
    'error'  => false,
  ];
});
```
> `should_scope()` returns true only when adding/editing `mrc_resource` and resource_type is `file`.

### Membership Check Helper
```php
function mrc_user_in_any_plan(int $user_id, array $plan_ids): bool {
  if (empty($plan_ids)) return wc_memberships_is_user_member($user_id); // any plan
  foreach ($plan_ids as $plan_id) {
    if (function_exists('wc_memberships_is_user_active_member') &&
        wc_memberships_is_user_active_member($user_id, (int) $plan_id)) return true;
  }
  return false;
}
```

### REST Download (issue token)
```php
register_rest_route('mrc/v1','/download/(?P<id>\\d+)', [
  'methods' => 'POST',
  'permission_callback' => fn() => is_user_logged_in(),
  'callback' => function(WP_REST_Request $req){
    $id = (int) $req['id'];
    $user = get_current_user_id();
    $plans = (array) get_field('requires_membership_plans',$id);
    if (!mrc_user_in_any_plan($user,$plans))
      return new WP_Error('forbidden','Membership required',['status'=>403]);

    $type = get_field('resource_type',$id);
    if ($type === 'external_link')
      return ['url' => esc_url_raw(get_field('external_url',$id))];

    $token = MRC\Tokens\TokenRepo::issue($user,$id,'+10 minutes');
    return ['url' => home_url('/mrc/download?token='.$token)];
  }
]);
```

### Template Redirect (serve file)
```php
add_action('template_redirect', function(){
  if (stripos($_SERVER['REQUEST_URI'] ?? '', '/mrc/download') === false) return;
  $token = $_GET['token'] ?? '';
  $row = MRC\Tokens\TokenRepo::consume($token);
  if (!$row) wp_die('Invalid or expired token', 403);

  $res_id = (int) $row['resource_id'];
  $plans  = (array) get_field('requires_membership_plans', $res_id);
  if (!mrc_user_in_any_plan(get_current_user_id(), $plans)) wp_die('Forbidden', 403);

  $abs = get_post_meta($res_id, '_mrc_private_path', true);
  if (!$abs || !file_exists($abs)) wp_die('File missing', 404);

  nocache_headers();
  header('Content-Type: application/octet-stream');
  header('Content-Disposition: attachment; filename="'.basename($abs).'"');
  header('Content-Length: '.filesize($abs));

  $fp = fopen($abs, 'rb');
  while (!feof($fp)) { echo fread($fp, 8192); @ob_flush(); flush(); }
  fclose($fp);
  exit;
});
```

---

## Settings (optional)
- Default audience behavior when no plan is set: require any active membership (toggle)
- Token TTL (minutes)
- Enable download logs (table `{prefix}mrc_download_logs`)

---

## Acceptance Checklist
- [ ] CPT + taxonomies registered; admin usable
- [ ] Media uploads from MRC are stored in **mrc-private** with `.htaccess` deny
- [ ] Frontend shortcode lists resources and supports filters
- [ ] Download flow issues short‑lived tokens; direct URLs never exposed
- [ ] Membership checks enforced for list and download
- [ ] External links returned only to authorized users
- [ ] Optional logs + cleanup cron for expired tokens

---

## Hand‑off to Codex (Tasks)
1. Scaffold plugin files per tree
2. Implement CPT/Taxonomies/Caps
3. Implement ACF group + conditional fields
4. Upload dir scoping + migration helper (move to private)
5. REST: `/resources`, `/download/{id}`
6. Token table + issue/consume logic
7. Template redirect download stream
8. Shortcode/grid view (PHP or small React)
9. Admin columns + filters
10. Settings (TTL, require-any-plan default, logging)

