mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
chore: update repo url
This commit is contained in:
@@ -9,7 +9,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
if: github.repository == 'iiketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
if: github.repository == 'iiketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
|
||||
@@ -9,7 +9,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-title:
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
if: github.repository == 'iiketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Flag or redirect issue
|
||||
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
if: github.repository == 'iiketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
outputs: type=image,name=iiketrek/TREK,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
@@ -145,16 +145,16 @@ jobs:
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
mapfile -t digests < <(printf 'iiketrek/TREK@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
|
||||
docker buildx imagetools create \
|
||||
-t "mauriceboe/trek:latest-pre" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
-t "iiketrek/TREK:latest-pre" \
|
||||
-t "iiketrek/TREK:$MAJOR_TAG" \
|
||||
-t "iiketrek/TREK:$VERSION" \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest-pre
|
||||
run: docker buildx imagetools inspect iiketrek/TREK:latest-pre
|
||||
|
||||
- name: Push git tag
|
||||
run: |
|
||||
|
||||
@@ -33,7 +33,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
if: github.repository == 'iiketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
outputs: type=image,name=iiketrek/TREK,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
@@ -194,16 +194,16 @@ jobs:
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
mapfile -t digests < <(printf 'iiketrek/TREK@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
docker buildx imagetools create \
|
||||
-t "mauriceboe/trek:latest" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
-t "iiketrek/TREK:latest" \
|
||||
-t "iiketrek/TREK:$MAJOR_TAG" \
|
||||
-t "iiketrek/TREK:$VERSION" \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||
run: docker buildx imagetools inspect iiketrek/TREK:latest
|
||||
|
||||
release-helm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check-target:
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
if: github.repository == 'iiketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
if: github.repository == 'iiketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
scout:
|
||||
# Docker Hub secrets are not exposed to pull requests from forks, so the
|
||||
# Scout login can never succeed there.
|
||||
if: github.repository == 'mauriceboe/TREK' && github.event.pull_request.head.repo.fork != true
|
||||
if: github.repository == 'iiketrek/TREK' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -17,7 +17,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
if: github.repository == 'iiketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -20,7 +20,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
<a href="https://hub.docker.com/r/iiketrek/TREK"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||
|
||||
@@ -31,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/iiketrek/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/iiketrek/TREK"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/iiketrek/TREK?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/iiketrek/TREK?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -176,7 +176,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads iiketrek/TREK
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
@@ -217,7 +217,7 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/trek:latest
|
||||
image: iiketrek/TREK:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
@@ -305,9 +305,9 @@ docker compose pull && docker compose up -d
|
||||
**Docker run** — reuse the original volume paths:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/trek
|
||||
docker pull iiketrek/TREK
|
||||
docker rm -f trek
|
||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped iiketrek/TREK
|
||||
```
|
||||
|
||||
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
+1
-1
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||
This policy covers the TREK application and its Docker image (`iiketrek/TREK`).
|
||||
|
||||
Third-party dependencies are monitored via GitHub Dependabot.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0"?>
|
||||
<CommunityApplications>
|
||||
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||
<Icon>https://raw.githubusercontent.com/iiketrek/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/liketrek/TREK</WebPage>
|
||||
<Forum>https://github.com/liketrek/TREK/issues</Forum>
|
||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
image:
|
||||
repository: mauriceboe/trek
|
||||
repository: iiketrek/TREK
|
||||
# tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useEffect, useState } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n';
|
||||
|
||||
const REPO = 'mauriceboe/TREK';
|
||||
const REPO = 'iiketrek/TREK';
|
||||
const PER_PAGE = 10;
|
||||
|
||||
interface GithubRelease {
|
||||
|
||||
@@ -336,14 +336,14 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
}}
|
||||
className="border border-gray-700 bg-gray-900 text-gray-100 dark:bg-gray-950"
|
||||
>
|
||||
{`docker pull mauriceboe/trek:latest
|
||||
{`docker pull iiketrek/TREK:latest
|
||||
docker stop trek && docker rm trek
|
||||
docker run -d --name trek \\
|
||||
-p 3000:3000 \\
|
||||
-v /opt/trek/data:/app/data \\
|
||||
-v /opt/trek/uploads:/app/uploads \\
|
||||
--restart unless-stopped \\
|
||||
mauriceboe/trek:latest`}
|
||||
iiketrek/TREK:latest`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/trek:latest
|
||||
image: iiketrek/TREK:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { NetworkCheck, CheckContext, RegistryEntry, RegistryEntryVersion }
|
||||
import { pass, fail, skip } from './types.js';
|
||||
import { REQUIRED_SECTIONS, MIN_PROSE_CHARS, missingSections, placeholders, proseLength, images, undocumentedPermissions } from './readme.js';
|
||||
|
||||
export const DEFAULT_REGISTRY = 'mauriceboe/TREK-Plugins';
|
||||
export const DEFAULT_REGISTRY = 'iiketrek/TREK-Plugins';
|
||||
|
||||
const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ Scripts are never prompted; pass --sign.
|
||||
Needs git and an authenticated \`gh\`.
|
||||
|
||||
--sign [key] sign the artifact (default key: ~/.trek-plugin/signing.key)
|
||||
--registry o/n a registry other than mauriceboe/TREK-Plugins
|
||||
--registry o/n a registry other than iiketrek/TREK-Plugins
|
||||
--notes release notes
|
||||
--draft open the registry PR as a draft
|
||||
--force overwrite an existing release's artifact. Breaks the sha256 pin for
|
||||
|
||||
@@ -12,7 +12,7 @@ import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readJsonFile } from './json.js';
|
||||
|
||||
const DEFAULT_REGISTRY = 'mauriceboe/TREK-Plugins';
|
||||
const DEFAULT_REGISTRY = 'iiketrek/TREK-Plugins';
|
||||
|
||||
interface EntryLike {
|
||||
id: string; name?: string; authorPublicKey?: string;
|
||||
|
||||
@@ -726,7 +726,7 @@ describe('pack + entry (publishing automation)', () => {
|
||||
const out = path.join(tmp, 'plugin.zip');
|
||||
const packed = packPluginDir(koffi, out);
|
||||
const entry = buildEntry({
|
||||
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
dir: koffi, repo: 'iiketrek/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z',
|
||||
});
|
||||
expect(entry.id).toBe('koffi');
|
||||
@@ -751,7 +751,7 @@ describe('pack + entry (publishing automation)', () => {
|
||||
const out = path.join(tmp, 'plugin.zip');
|
||||
packPluginDir(koffi, out);
|
||||
const entry = buildEntry({
|
||||
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
dir: koffi, repo: 'iiketrek/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z',
|
||||
});
|
||||
expect(entry.icon).toBe('Luggage'); // koffi's trek-plugin.json declares it
|
||||
@@ -764,7 +764,7 @@ describe('pack + entry (publishing automation)', () => {
|
||||
const p = path.join(tmp, `prior-${icon}.json`);
|
||||
fs.writeFileSync(p, JSON.stringify({
|
||||
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x',
|
||||
repo: 'mauriceboe/trek-plugin-koffi', type: 'widget', icon,
|
||||
repo: 'iiketrek/TREK-plugin-koffi', type: 'widget', icon,
|
||||
versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/y.zip', sha256: 'c'.repeat(64), size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }],
|
||||
}));
|
||||
return p;
|
||||
@@ -772,7 +772,7 @@ describe('pack + entry (publishing automation)', () => {
|
||||
|
||||
// koffi's manifest declares Luggage → an author who rebrands sees it in the store
|
||||
const rebranded = buildEntry({
|
||||
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
dir: koffi, repo: 'iiketrek/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z', mergePath: prior('Coffee'),
|
||||
});
|
||||
expect(rebranded.icon).toBe('Luggage');
|
||||
@@ -820,11 +820,11 @@ describe('pack + entry (publishing automation)', () => {
|
||||
packPluginDir(koffi, out);
|
||||
const existingPath = path.join(tmp, 'koffi.json');
|
||||
fs.writeFileSync(existingPath, JSON.stringify({
|
||||
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x', repo: 'mauriceboe/trek-plugin-koffi', type: 'widget',
|
||||
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x', repo: 'iiketrek/TREK-plugin-koffi', type: 'widget',
|
||||
versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://github.com/x/y/releases/download/v0.9.0/plugin.zip', sha256: 'c'.repeat(64), minTrekVersion: '3.2.0', size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }],
|
||||
}));
|
||||
const merged = buildEntry({
|
||||
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
dir: koffi, repo: 'iiketrek/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out,
|
||||
commit: 'a'.repeat(40), mergePath: existingPath, now: '2026-07-04T00:00:00.000Z',
|
||||
});
|
||||
expect(merged.versions.map((v) => v.version)).toEqual(['1.0.0', '0.9.0']);
|
||||
@@ -868,7 +868,7 @@ describe('sign + keygen (author signatures, TOFU)', () => {
|
||||
packPluginDir(koffi, out);
|
||||
const keyPath = path.join(tmp, 'k.pem');
|
||||
const { publicKey } = generateKeypair(keyPath);
|
||||
const entry = buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: keyPath, now: '2026-07-04T00:00:00.000Z' });
|
||||
const entry = buildEntry({ dir: koffi, repo: 'iiketrek/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: keyPath, now: '2026-07-04T00:00:00.000Z' });
|
||||
expect(entry.authorPublicKey).toBe(publicKey);
|
||||
const sig = entry.versions[0].signature;
|
||||
expect(sig).toBeTruthy();
|
||||
@@ -880,12 +880,12 @@ describe('sign + keygen (author signatures, TOFU)', () => {
|
||||
packPluginDir(koffi, out);
|
||||
const key1 = path.join(tmp, 'k1.pem');
|
||||
generateKeypair(key1);
|
||||
const existing = buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: key1, now: '2026-07-04T00:00:00.000Z' });
|
||||
const existing = buildEntry({ dir: koffi, repo: 'iiketrek/TREK-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: key1, now: '2026-07-04T00:00:00.000Z' });
|
||||
const existingPath = path.join(tmp, 'koffi.json');
|
||||
fs.writeFileSync(existingPath, JSON.stringify({ ...existing, versions: existing.versions.map((v) => ({ ...v, version: '0.9.0', gitTag: 'v0.9.0' })) }));
|
||||
const key2 = path.join(tmp, 'k2.pem');
|
||||
generateKeypair(key2);
|
||||
expect(() => buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.1.0', zipPath: out, commit: 'a'.repeat(40), mergePath: existingPath, signKeyPath: key2, now: '2026-07-04T00:00:00.000Z' })).toThrow(/differs from the one already published/);
|
||||
expect(() => buildEntry({ dir: koffi, repo: 'iiketrek/TREK-plugin-koffi', tag: 'v1.1.0', zipPath: out, commit: 'a'.repeat(40), mergePath: existingPath, signKeyPath: key2, now: '2026-07-04T00:00:00.000Z' })).toThrow(/differs from the one already published/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import semver from 'semver';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { db } from '../../../db/database';
|
||||
import { discoverPlugins } from '../install/discovery';
|
||||
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
|
||||
import type { PluginDependency } from '../install/manifest';
|
||||
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
|
||||
import { parseJsonText, parseManifest } from '../install/manifest';
|
||||
import { scanForNativeBinaries } from '../install/native-scan';
|
||||
import { extractArchive } from '../install/safe-extract';
|
||||
import { safeDownload, sha256Matches } from '../install/safe-fetch';
|
||||
import { verifyAuthorSignature, SignatureError } from '../install/verify-signature';
|
||||
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
|
||||
import { clearUpdateBlock, isSignatureCode, setUpdateBlock, RETRUSTABLE_CODE } from '../signature-status';
|
||||
import { extractArchive } from '../install/safe-extract';
|
||||
import { scanForNativeBinaries } from '../install/native-scan';
|
||||
import { parseJsonText, parseManifest } from '../install/manifest';
|
||||
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
|
||||
import { discoverPlugins } from '../install/discovery';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
|
||||
/**
|
||||
* TREK-side of the plugin registry (#plugins, M5). Fetches the single aggregated
|
||||
@@ -25,7 +26,7 @@ import { discoverPlugins } from '../install/discovery';
|
||||
|
||||
const REGISTRY_URL =
|
||||
process.env.TREK_PLUGIN_REGISTRY_URL ||
|
||||
'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json';
|
||||
'https://raw.githubusercontent.com/iiketrek/TREK-Plugins/main/dist/index.json';
|
||||
const CACHE_TTL = 30 * 60 * 1000;
|
||||
const MANIFEST_MAX_BYTES = 256 * 1024;
|
||||
// Sideload upload ceiling — matches the SDK `pack` limit (50 MB) plus zip overhead.
|
||||
@@ -151,7 +152,10 @@ export class PluginRegistryService {
|
||||
try {
|
||||
const url = force ? `${REGISTRY_URL}${REGISTRY_URL.includes('?') ? '&' : '?'}_=${Date.now()}` : REGISTRY_URL;
|
||||
const headers: Record<string, string> = { 'User-Agent': 'TREK-Server' };
|
||||
if (force) { headers['Cache-Control'] = 'no-cache'; headers.Pragma = 'no-cache'; }
|
||||
if (force) {
|
||||
headers['Cache-Control'] = 'no-cache';
|
||||
headers.Pragma = 'no-cache';
|
||||
}
|
||||
const resp = await fetch(url, { headers });
|
||||
if (!resp.ok) throw new Error(`registry ${resp.status}`);
|
||||
const data = (await resp.json()) as Registry;
|
||||
@@ -172,16 +176,40 @@ export class PluginRegistryService {
|
||||
* `authorPublicKey` is exposed in full — it is a public key, and the re-trust
|
||||
* round-trip compares it exactly (a truncated fingerprint would be a weak check).
|
||||
*/
|
||||
async browse(force = false): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; requiredAddons: string[]; pluginDependencies: PluginDependency[]; screenshotUrl: string | null; signed: boolean; authorPublicKey: string | null } & HostCompat>> {
|
||||
async browse(
|
||||
force = false,
|
||||
): Promise<
|
||||
Array<
|
||||
Omit<RegistryEntry, 'versions'> & {
|
||||
latest: string | null;
|
||||
minTrekVersion: string | null;
|
||||
requiredAddons: string[];
|
||||
pluginDependencies: PluginDependency[];
|
||||
screenshotUrl: string | null;
|
||||
signed: boolean;
|
||||
authorPublicKey: string | null;
|
||||
} & HostCompat
|
||||
>
|
||||
> {
|
||||
const reg = await this.fetchRegistry(force);
|
||||
return reg.plugins.map((p) => {
|
||||
const latest = p.versions[0] ?? null;
|
||||
return {
|
||||
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
|
||||
homepage: p.homepage, tags: p.tags, type: p.type, icon: p.icon, reviewedAt: p.reviewedAt ?? null,
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
author: p.author,
|
||||
description: p.description,
|
||||
repo: p.repo,
|
||||
homepage: p.homepage,
|
||||
tags: p.tags,
|
||||
type: p.type,
|
||||
icon: p.icon,
|
||||
reviewedAt: p.reviewedAt ?? null,
|
||||
downloadCount: p.downloadCount ?? null,
|
||||
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
latest: latest?.version ?? null,
|
||||
minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [],
|
||||
pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null,
|
||||
signed: !!p.authorPublicKey && !!latest?.signature,
|
||||
authorPublicKey: p.authorPublicKey ?? null,
|
||||
@@ -230,7 +258,10 @@ export class PluginRegistryService {
|
||||
let manifest: ManifestPreview | null = null;
|
||||
if (latest) {
|
||||
try {
|
||||
const { bytes } = await safeDownload(rawFileUrl(entry.repo, latest.commitSha, 'trek-plugin.json'), MANIFEST_MAX_BYTES);
|
||||
const { bytes } = await safeDownload(
|
||||
rawFileUrl(entry.repo, latest.commitSha, 'trek-plugin.json'),
|
||||
MANIFEST_MAX_BYTES,
|
||||
);
|
||||
manifest = previewManifest(parseJsonText(bytes.toString('utf8')));
|
||||
} catch {
|
||||
// Soft-fail: the detail view still renders from registry metadata alone.
|
||||
@@ -238,12 +269,22 @@ export class PluginRegistryService {
|
||||
}
|
||||
|
||||
const data = {
|
||||
id: entry.id, name: entry.name, author: entry.author, description: entry.description,
|
||||
repo: entry.repo, homepage: entry.homepage ?? null, tags: entry.tags ?? [], type: entry.type,
|
||||
reviewedAt: entry.reviewedAt ?? null, downloadCount: entry.downloadCount ?? null,
|
||||
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
author: entry.author,
|
||||
description: entry.description,
|
||||
repo: entry.repo,
|
||||
homepage: entry.homepage ?? null,
|
||||
tags: entry.tags ?? [],
|
||||
type: entry.type,
|
||||
reviewedAt: entry.reviewedAt ?? null,
|
||||
downloadCount: entry.downloadCount ?? null,
|
||||
latest: latest?.version ?? null,
|
||||
minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
size: latest?.size ?? null,
|
||||
publishedAt: latest?.publishedAt ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [],
|
||||
pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null,
|
||||
signed: !!entry.authorPublicKey && !!latest?.signature,
|
||||
authorPublicKey: entry.authorPublicKey ?? null,
|
||||
@@ -336,7 +377,10 @@ export class PluginRegistryService {
|
||||
* blessed key that doesn't actually sign the code is refused like any other bad
|
||||
* signature. It is never a way to install something unverified.
|
||||
*/
|
||||
async install(id: string, opts?: { version?: string; constraint?: string; retrustKey?: string }): Promise<{ id: string; version: string }> {
|
||||
async install(
|
||||
id: string,
|
||||
opts?: { version?: string; constraint?: string; retrustKey?: string },
|
||||
): Promise<{ id: string; version: string }> {
|
||||
const reg = await this.fetchRegistry();
|
||||
const entry = reg.plugins.find((p) => p.id === id);
|
||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
||||
@@ -366,7 +410,10 @@ export class PluginRegistryService {
|
||||
if (!pluginRoot) throw new RegistryError('archive contains no trek-plugin.json');
|
||||
|
||||
// 4. re-validate the bundled manifest + 5. native re-scan
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')), { requireTrek: true });
|
||||
const manifest = parseManifest(
|
||||
parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')),
|
||||
{ requireTrek: true },
|
||||
);
|
||||
if (manifest.id !== id) throw new RegistryError(`manifest id "${manifest.id}" != "${id}"`);
|
||||
// The artifact's OWN range is the authoritative compat check. The index metadata
|
||||
// gated above is only what the registry says about this version, and it is weaker:
|
||||
@@ -385,7 +432,11 @@ export class PluginRegistryService {
|
||||
// 7. register INACTIVE (record provenance)
|
||||
discoverPlugins(db);
|
||||
db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
|
||||
entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null, id,
|
||||
entry.repo,
|
||||
ver.commitSha,
|
||||
ver.sha256,
|
||||
entry.reviewedAt ?? null,
|
||||
id,
|
||||
);
|
||||
// Pin the author key on first successful install of a signed plugin (TOFU) —
|
||||
// and, after a re-trust, re-pin to the new key the admin blessed. Only ever set
|
||||
@@ -410,8 +461,13 @@ export class PluginRegistryService {
|
||||
* can't be installed; they're collected and returned so the caller can prompt the
|
||||
* admin to enable them. Cycle-safe. Already-installed plugins are left untouched.
|
||||
*/
|
||||
async installWithDependencies(id: string, constraint?: string): Promise<{ installed: string[]; requiredAddons: string[] }> {
|
||||
const installedNow = new Set((db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id));
|
||||
async installWithDependencies(
|
||||
id: string,
|
||||
constraint?: string,
|
||||
): Promise<{ installed: string[]; requiredAddons: string[] }> {
|
||||
const installedNow = new Set(
|
||||
(db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id),
|
||||
);
|
||||
const done = new Set<string>();
|
||||
const installed: string[] = [];
|
||||
const requiredAddons = new Set<string>();
|
||||
@@ -451,7 +507,9 @@ export class PluginRegistryService {
|
||||
extractArchive(bytes, stagingDir);
|
||||
const root = locateManifestDir(stagingDir);
|
||||
if (!root) throw new RegistryError('archive contains no trek-plugin.json');
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')), { requireTrek: true });
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')), {
|
||||
requireTrek: true,
|
||||
});
|
||||
assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
if (scanForNativeBinaries(root).length) throw new RegistryError('artifact contains native binaries');
|
||||
return { id: manifest.id, version: manifest.version, root, stagingDir };
|
||||
@@ -514,17 +572,31 @@ export class PluginRegistryService {
|
||||
* key, over the artifact bytes. A key an admin blesses must still sign the code it
|
||||
* ships — a re-trust moves the pin from one VERIFIED key to another verified key.
|
||||
*/
|
||||
private verifySignatureAndTofu(id: string, bytes: Buffer, entry: RegistryEntry, ver: RegistryVersion, retrustKey?: string): void {
|
||||
const pinned = (db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)?.author_pubkey ?? null;
|
||||
private verifySignatureAndTofu(
|
||||
id: string,
|
||||
bytes: Buffer,
|
||||
entry: RegistryEntry,
|
||||
ver: RegistryVersion,
|
||||
retrustKey?: string,
|
||||
): void {
|
||||
const pinned =
|
||||
(db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)
|
||||
?.author_pubkey ?? null;
|
||||
|
||||
if (!entry.authorPublicKey && !ver.signature) {
|
||||
if (pinned) {
|
||||
throw new RegistryError('this plugin was signed before but the update is unsigned — refusing', 'SIGNATURE_MISSING');
|
||||
throw new RegistryError(
|
||||
'this plugin was signed before but the update is unsigned — refusing',
|
||||
'SIGNATURE_MISSING',
|
||||
);
|
||||
}
|
||||
return; // unsigned throughout: sha256 is the only pin
|
||||
}
|
||||
if (!entry.authorPublicKey || !ver.signature) {
|
||||
throw new RegistryError('incomplete signature: an author key and a version signature must both be present', 'SIGNATURE_INCOMPLETE');
|
||||
throw new RegistryError(
|
||||
'incomplete signature: an author key and a version signature must both be present',
|
||||
'SIGNATURE_INCOMPLETE',
|
||||
);
|
||||
}
|
||||
if (pinned && pinned !== entry.authorPublicKey && retrustKey !== entry.authorPublicKey) {
|
||||
throw new RegistryError(
|
||||
@@ -574,7 +646,10 @@ export class PluginRegistryService {
|
||||
|
||||
const pinned = row.author_pubkey ?? null;
|
||||
if (!pinned || !entry.authorPublicKey || pinned === entry.authorPublicKey) {
|
||||
throw new RegistryError("this plugin's signing key has not changed — there is nothing to re-trust", 'RETRUST_NOT_APPLICABLE');
|
||||
throw new RegistryError(
|
||||
"this plugin's signing key has not changed — there is nothing to re-trust",
|
||||
'RETRUST_NOT_APPLICABLE',
|
||||
);
|
||||
}
|
||||
if (entry.authorPublicKey !== publicKey) {
|
||||
throw new RegistryError(
|
||||
@@ -615,10 +690,7 @@ function hostCompatible(v: RegistryVersion, host: string | null): boolean {
|
||||
*/
|
||||
export function assertHostCompatible(range: string | null, id: string): void {
|
||||
if (hostSatisfies(range)) return;
|
||||
throw new RegistryError(
|
||||
`${id} requires TREK ${range} — this is TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
);
|
||||
throw new RegistryError(`${id} requires TREK ${range} — this is TREK ${hostVersion()}`, 'TREK_VERSION_INCOMPATIBLE');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -660,7 +732,10 @@ function previewManifest(raw: unknown): ManifestPreview {
|
||||
const pluginDependencies = Array.isArray(m.pluginDependencies)
|
||||
? m.pluginDependencies
|
||||
.filter((d): d is Record<string, unknown> => !!d && typeof d === 'object')
|
||||
.map((d) => ({ id: typeof d.id === 'string' ? d.id : '', version: typeof d.version === 'string' ? d.version : '' }))
|
||||
.map((d) => ({
|
||||
id: typeof d.id === 'string' ? d.id : '',
|
||||
version: typeof d.version === 'string' ? d.version : '',
|
||||
}))
|
||||
.filter((d) => d.id && d.version)
|
||||
: [];
|
||||
return {
|
||||
|
||||
+330
-150
@@ -1,22 +1,23 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db } from '../db/database';
|
||||
import { User, Addon } from '../types';
|
||||
import { updateJwtSecret } from '../config';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
||||
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
||||
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
import { emitUserDeleted } from '../plugin-user-lifecycle';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
import { updateJwtSecret } from '../config';
|
||||
import { db } from '../db/database';
|
||||
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
||||
import { emitUserDeleted } from '../plugin-user-lifecycle';
|
||||
import { User, Addon } from '../types';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
||||
import { resolveAuthToggles } from './authService';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,9 +37,11 @@ export function compareVersions(a: string, b: string): number {
|
||||
const preN = n !== null && Number.isFinite(n) ? n : null;
|
||||
return { parts, preN };
|
||||
};
|
||||
const pa = parse(a), pb = parse(b);
|
||||
const pa = parse(a),
|
||||
pb = parse(b);
|
||||
for (let i = 0; i < Math.max(pa.parts.length, pb.parts.length); i++) {
|
||||
const na = pa.parts[i] || 0, nb = pb.parts[i] || 0;
|
||||
const na = pa.parts[i] || 0,
|
||||
nb = pb.parts[i] || 0;
|
||||
if (na > nb) return 1;
|
||||
if (na < nb) return -1;
|
||||
}
|
||||
@@ -54,8 +57,13 @@ export function compareVersions(a: string, b: string): number {
|
||||
|
||||
export const isDocker = (() => {
|
||||
try {
|
||||
return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'));
|
||||
} catch { return false; }
|
||||
return (
|
||||
fs.existsSync('/.dockerenv') ||
|
||||
(fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// ── User CRUD ──────────────────────────────────────────────────────────────
|
||||
@@ -63,15 +71,21 @@ export const isDocker = (() => {
|
||||
export function listUsers() {
|
||||
// Guests (#1362) are accountless trip participants, not real users — keep them out
|
||||
// of admin user management entirely.
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC'
|
||||
).all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & { avatar?: string | null })[];
|
||||
const users = db
|
||||
.prepare(
|
||||
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC',
|
||||
)
|
||||
.all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & {
|
||||
avatar?: string | null;
|
||||
})[];
|
||||
let onlineUserIds = new Set<number>();
|
||||
try {
|
||||
const { getOnlineUserIds } = require('../websocket');
|
||||
onlineUserIds = getOnlineUserIds();
|
||||
} catch { /* */ }
|
||||
return users.map(u => ({
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
return users.map((u) => ({
|
||||
...u,
|
||||
avatar_url: avatarUrl(u),
|
||||
created_at: utcSuffix(u.created_at),
|
||||
@@ -98,7 +112,9 @@ export function createUser(data: { username: string; email: string; password: st
|
||||
}
|
||||
|
||||
// Guests (#1362) live in a reserved synthetic namespace; never let one block a real account.
|
||||
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ? AND COALESCE(is_guest, 0) = 0').get(username);
|
||||
const existingUsername = db
|
||||
.prepare('SELECT id FROM users WHERE username = ? AND COALESCE(is_guest, 0) = 0')
|
||||
.get(username);
|
||||
if (existingUsername) return { error: 'Username already taken', status: 409 };
|
||||
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0').get(email);
|
||||
@@ -106,13 +122,13 @@ export function createUser(data: { username: string; email: string; password: st
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||
).run(username, email, passwordHash, data.role || 'user');
|
||||
const result = db
|
||||
.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
|
||||
.run(username, email, passwordHash, data.role || 'user');
|
||||
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||
).get(result.lastInsertRowid);
|
||||
const user = db
|
||||
.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
|
||||
return {
|
||||
user,
|
||||
@@ -134,11 +150,15 @@ export function updateUser(id: string, data: { username?: string; email?: string
|
||||
}
|
||||
|
||||
if (username && username !== user.username) {
|
||||
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ? AND COALESCE(is_guest, 0) = 0').get(username, id);
|
||||
const conflict = db
|
||||
.prepare('SELECT id FROM users WHERE username = ? AND id != ? AND COALESCE(is_guest, 0) = 0')
|
||||
.get(username, id);
|
||||
if (conflict) return { error: 'Username already taken', status: 409 };
|
||||
}
|
||||
if (email && email !== user.email) {
|
||||
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0').get(email, id);
|
||||
const conflict = db
|
||||
.prepare('SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0')
|
||||
.get(email, id);
|
||||
if (conflict) return { error: 'Email already taken', status: 409 };
|
||||
}
|
||||
|
||||
@@ -153,12 +173,15 @@ export function updateUser(id: string, data: { username?: string; email?: string
|
||||
if (role && role !== 'admin') {
|
||||
const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined;
|
||||
if (current?.role === 'admin') {
|
||||
const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count;
|
||||
const adminCount = (
|
||||
db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }
|
||||
).count;
|
||||
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE users SET
|
||||
username = COALESCE(?, username),
|
||||
email = COALESCE(?, email),
|
||||
@@ -166,11 +189,12 @@ export function updateUser(id: string, data: { username?: string; email?: string
|
||||
password_hash = COALESCE(?, password_hash),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(username || null, email || null, role || null, passwordHash, id);
|
||||
`,
|
||||
).run(username || null, email || null, role || null, passwordHash, id);
|
||||
|
||||
const updated = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
|
||||
).get(id);
|
||||
const updated = db
|
||||
.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?')
|
||||
.get(id);
|
||||
|
||||
const changed: string[] = [];
|
||||
if (username) changed.push('username');
|
||||
@@ -190,7 +214,9 @@ export function deleteUser(id: string, currentUserId: number) {
|
||||
return { error: 'Cannot delete own account', status: 400 };
|
||||
}
|
||||
|
||||
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined;
|
||||
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as
|
||||
| { id: number; email: string }
|
||||
| undefined;
|
||||
if (!userToDel) return { error: 'User not found', status: 404 };
|
||||
|
||||
deleteUserCompletely(userToDel.id);
|
||||
@@ -201,7 +227,9 @@ export function deleteUser(id: string, currentUserId: number) {
|
||||
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getStats() {
|
||||
const totalUsers = (db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count;
|
||||
const totalUsers = (
|
||||
db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }
|
||||
).count;
|
||||
const totalTrips = (db.prepare('SELECT COUNT(*) as count FROM trips').get() as { count: number }).count;
|
||||
const totalPlaces = (db.prepare('SELECT COUNT(*) as count FROM places').get() as { count: number }).count;
|
||||
const totalFiles = (db.prepare('SELECT COUNT(*) as count FROM trip_files').get() as { count: number }).count;
|
||||
@@ -212,7 +240,7 @@ export function getStats() {
|
||||
|
||||
export function getPermissions() {
|
||||
const current = getAllPermissions();
|
||||
const actions = PERMISSION_ACTIONS.map(a => ({
|
||||
const actions = PERMISSION_ACTIONS.map((a) => ({
|
||||
key: a.key,
|
||||
level: current[a.key],
|
||||
defaultLevel: a.defaultLevel,
|
||||
@@ -246,13 +274,17 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
|
||||
ip: string | null;
|
||||
};
|
||||
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
ORDER BY a.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(limit, offset) as Row[];
|
||||
`,
|
||||
)
|
||||
.all(limit, offset) as Row[];
|
||||
|
||||
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
|
||||
|
||||
@@ -265,7 +297,8 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
|
||||
details = { _parse_error: true };
|
||||
}
|
||||
}
|
||||
const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
|
||||
const created_at =
|
||||
r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
|
||||
return { ...r, created_at, details };
|
||||
});
|
||||
|
||||
@@ -275,7 +308,8 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
|
||||
// ── OIDC Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getOidcSettings() {
|
||||
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
|
||||
const get = (key: string) =>
|
||||
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || '';
|
||||
const secret = decrypt_api_key(get('oidc_client_secret'));
|
||||
return {
|
||||
issuer: get('oidc_issuer'),
|
||||
@@ -296,10 +330,14 @@ export function updateOidcSettings(data: {
|
||||
}): { error?: string; status?: number; success?: boolean } {
|
||||
// Lockout prevention: can't remove OIDC config when password login is disabled
|
||||
if ((data.issuer === '' || data.client_id === '') && !resolveAuthToggles().password_login) {
|
||||
return { error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.', status: 400 };
|
||||
return {
|
||||
error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.',
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
|
||||
const set = (key: string, val: string) =>
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, val || '');
|
||||
set('oidc_issuer', data.issuer ?? '');
|
||||
set('oidc_client_id', data.client_id ?? '');
|
||||
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
|
||||
@@ -328,10 +366,9 @@ export function saveDemoBaseline(): { error?: string; status?: number; message?:
|
||||
|
||||
export async function getGithubReleases(perPage: string = '10', page: string = '1') {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${perPage}&page=${page}`,
|
||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
||||
);
|
||||
const resp = await fetch(`https://api.github.com/repos/iiketrek/TREK/releases?per_page=${perPage}&page=${page}`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
@@ -364,41 +401,59 @@ export async function checkVersion(): Promise<VersionInfo> {
|
||||
|
||||
const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version;
|
||||
const isPrerelease = currentVersion.includes('-pre.');
|
||||
const fallback: VersionInfo = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease };
|
||||
const fallback: VersionInfo = {
|
||||
current: currentVersion,
|
||||
latest: currentVersion,
|
||||
update_available: false,
|
||||
is_docker: isDocker,
|
||||
is_prerelease: isPrerelease,
|
||||
};
|
||||
let result: VersionInfo;
|
||||
try {
|
||||
if (isPrerelease) {
|
||||
// Fetch release list and find the newest prerelease
|
||||
const resp = await fetch(
|
||||
'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=100',
|
||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
||||
);
|
||||
const resp = await fetch('https://api.github.com/repos/iiketrek/TREK/releases?per_page=100', {
|
||||
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
return fallback;
|
||||
}
|
||||
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
|
||||
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
|
||||
const data = (await resp.json()) as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
|
||||
const prereleases = Array.isArray(data) ? data.filter((r) => r.prerelease) : [];
|
||||
if (!prereleases.length) {
|
||||
return fallback;
|
||||
}
|
||||
// Pre-compute stripped versions, then sort descending
|
||||
const tagged = prereleases.map(r => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
|
||||
const tagged = prereleases.map((r) => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
|
||||
tagged.sort((a, b) => compareVersions(b.v, a.v));
|
||||
const latest = tagged[0].v;
|
||||
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
|
||||
result = { current: currentVersion, latest, update_available, release_url: tagged[0].r.html_url || '', is_docker: isDocker, is_prerelease: true };
|
||||
result = {
|
||||
current: currentVersion,
|
||||
latest,
|
||||
update_available,
|
||||
release_url: tagged[0].r.html_url || '',
|
||||
is_docker: isDocker,
|
||||
is_prerelease: true,
|
||||
};
|
||||
} else {
|
||||
const resp = await fetch(
|
||||
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
|
||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
||||
);
|
||||
const resp = await fetch('https://api.github.com/repos/iiketrek/TREK/releases/latest', {
|
||||
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
return fallback;
|
||||
}
|
||||
const data = await resp.json() as { tag_name?: string; html_url?: string };
|
||||
const data = (await resp.json()) as { tag_name?: string; html_url?: string };
|
||||
const latest = (data.tag_name || '').replace(/^v/, '');
|
||||
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
|
||||
result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
|
||||
result = {
|
||||
current: currentVersion,
|
||||
latest,
|
||||
update_available,
|
||||
release_url: data.html_url || '',
|
||||
is_docker: isDocker,
|
||||
is_prerelease: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return fallback;
|
||||
@@ -413,10 +468,17 @@ export async function checkAndNotifyVersion(): Promise<void> {
|
||||
const result = await checkVersion();
|
||||
if (!result.update_available) return;
|
||||
|
||||
const lastNotified = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
||||
const lastNotified = (
|
||||
db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
)?.value;
|
||||
if (lastNotified === result.latest) return;
|
||||
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', result.latest);
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
||||
'last_notified_version',
|
||||
result.latest,
|
||||
);
|
||||
|
||||
await sendNotification({
|
||||
event: 'version_available',
|
||||
@@ -433,13 +495,17 @@ export async function checkAndNotifyVersion(): Promise<void> {
|
||||
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
||||
|
||||
export function listInvites() {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT i.*, u.username as created_by_name, t.title as trip_title
|
||||
FROM invite_tokens i
|
||||
JOIN users u ON i.created_by = u.id
|
||||
LEFT JOIN trips t ON i.trip_id = t.id
|
||||
ORDER BY i.created_at DESC
|
||||
`).all();
|
||||
`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
/** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */
|
||||
@@ -447,7 +513,10 @@ export function listTripsForInvite() {
|
||||
return db.prepare('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC').all();
|
||||
}
|
||||
|
||||
export function createInvite(createdBy: number, data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null }) {
|
||||
export function createInvite(
|
||||
createdBy: number,
|
||||
data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null },
|
||||
) {
|
||||
const rawUses = parseInt(String(data.max_uses));
|
||||
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
@@ -465,18 +534,22 @@ export function createInvite(createdBy: number, data: { max_uses?: string | numb
|
||||
}
|
||||
}
|
||||
|
||||
const ins = db.prepare(
|
||||
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(token, uses, expiresAt, createdBy, tripId);
|
||||
const ins = db
|
||||
.prepare('INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(token, uses, expiresAt, createdBy, tripId);
|
||||
|
||||
const inviteId = Number(ins.lastInsertRowid);
|
||||
const invite = db.prepare(`
|
||||
const invite = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT i.*, u.username as created_by_name, t.title as trip_title
|
||||
FROM invite_tokens i
|
||||
JOIN users u ON i.created_by = u.id
|
||||
LEFT JOIN trips t ON i.trip_id = t.id
|
||||
WHERE i.id = ?
|
||||
`).get(inviteId);
|
||||
`,
|
||||
)
|
||||
.get(inviteId);
|
||||
|
||||
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId };
|
||||
}
|
||||
@@ -491,57 +564,82 @@ export function deleteInvite(id: string) {
|
||||
// ── Bag Tracking ───────────────────────────────────────────────────────────
|
||||
|
||||
export function getBagTracking() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value === 'true' };
|
||||
}
|
||||
|
||||
export function updateBagTracking(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(
|
||||
enabled ? 'true' : 'false',
|
||||
);
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Photos ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesPhotos() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesPhotos(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(
|
||||
enabled ? 'true' : 'false',
|
||||
);
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Autocomplete ────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesAutocomplete() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesAutocomplete(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(
|
||||
enabled ? 'true' : 'false',
|
||||
);
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Details ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesDetails() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesDetails(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(
|
||||
enabled ? 'true' : 'false',
|
||||
);
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
|
||||
const COLLAB_FEATURE_KEYS = [
|
||||
'collab_chat_enabled',
|
||||
'collab_notes_enabled',
|
||||
'collab_polls_enabled',
|
||||
'collab_whatsnext_enabled',
|
||||
] as const;
|
||||
|
||||
export function getCollabFeatures() {
|
||||
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").all() as { key: string; value: string }[];
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
|
||||
)
|
||||
.all() as { key: string; value: string }[];
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
return {
|
||||
@@ -552,10 +650,20 @@ export function getCollabFeatures() {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
|
||||
const mapping: Record<string, string> = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' };
|
||||
export function updateCollabFeatures(features: {
|
||||
chat?: boolean;
|
||||
notes?: boolean;
|
||||
polls?: boolean;
|
||||
whatsnext?: boolean;
|
||||
}) {
|
||||
const mapping: Record<string, string> = {
|
||||
chat: 'collab_chat_enabled',
|
||||
notes: 'collab_notes_enabled',
|
||||
polls: 'collab_polls_enabled',
|
||||
whatsnext: 'collab_whatsnext_enabled',
|
||||
};
|
||||
const before = getCollabFeatures();
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||
const stmt = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
|
||||
for (const [feat, key] of Object.entries(mapping)) {
|
||||
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
|
||||
}
|
||||
@@ -563,38 +671,50 @@ export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean
|
||||
// Collab flags gate MCP tool/resource registration, so callers must know
|
||||
// whether anything actually flipped — a no-op save must not tear down every
|
||||
// live MCP session (#1414).
|
||||
const changed = (Object.keys(after) as Array<keyof typeof after>).some(k => after[k] !== before[k]);
|
||||
const changed = (Object.keys(after) as Array<keyof typeof after>).some((k) => after[k] !== before[k]);
|
||||
return { features: after, changed };
|
||||
}
|
||||
|
||||
// ── Packing Templates ──────────────────────────────────────────────────────
|
||||
|
||||
export function listPackingTemplates() {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT pt.*, u.username as created_by_name,
|
||||
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
|
||||
(SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count
|
||||
FROM packing_templates pt
|
||||
JOIN users u ON pt.created_by = u.id
|
||||
ORDER BY pt.created_at DESC
|
||||
`).all();
|
||||
`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getPackingTemplate(id: string) {
|
||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id);
|
||||
if (!template) return { error: 'Template not found', status: 404 };
|
||||
const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(id) as any[];
|
||||
const items = db.prepare(`
|
||||
const categories = db
|
||||
.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id')
|
||||
.all(id) as any[];
|
||||
const items = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT ti.* FROM packing_template_items ti
|
||||
JOIN packing_template_categories tc ON ti.category_id = tc.id
|
||||
WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id
|
||||
`).all(id);
|
||||
`,
|
||||
)
|
||||
.all(id);
|
||||
return { template, categories, items };
|
||||
}
|
||||
|
||||
export function createPackingTemplate(name: string, createdBy: number) {
|
||||
if (!name?.trim()) return { error: 'Name is required', status: 400 };
|
||||
const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), createdBy);
|
||||
const result = db
|
||||
.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)')
|
||||
.run(name.trim(), createdBy);
|
||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid);
|
||||
return { template };
|
||||
}
|
||||
@@ -619,20 +739,29 @@ export function createTemplateCategory(templateId: string, name: string) {
|
||||
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
|
||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(templateId);
|
||||
if (!template) return { error: 'Template not found', status: 404 };
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(templateId) as { max: number | null };
|
||||
const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, name.trim(), (maxOrder.max ?? -1) + 1);
|
||||
const maxOrder = db
|
||||
.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?')
|
||||
.get(templateId) as { max: number | null };
|
||||
const result = db
|
||||
.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)')
|
||||
.run(templateId, name.trim(), (maxOrder.max ?? -1) + 1);
|
||||
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) };
|
||||
}
|
||||
|
||||
export function updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
|
||||
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
|
||||
const cat = db
|
||||
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
|
||||
.get(catId, templateId);
|
||||
if (!cat) return { error: 'Category not found', status: 404 };
|
||||
if (data.name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId);
|
||||
if (data.name?.trim())
|
||||
db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId);
|
||||
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(catId) };
|
||||
}
|
||||
|
||||
export function deleteTemplateCategory(templateId: string, catId: string) {
|
||||
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
|
||||
const cat = db
|
||||
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
|
||||
.get(catId, templateId);
|
||||
if (!cat) return { error: 'Category not found', status: 404 };
|
||||
db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(catId);
|
||||
return {};
|
||||
@@ -642,17 +771,24 @@ export function deleteTemplateCategory(templateId: string, catId: string) {
|
||||
|
||||
export function createTemplateItem(templateId: string, catId: string, name: string) {
|
||||
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
|
||||
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
|
||||
const cat = db
|
||||
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
|
||||
.get(catId, templateId);
|
||||
if (!cat) return { error: 'Category not found', status: 404 };
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(catId) as { max: number | null };
|
||||
const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, name.trim(), (maxOrder.max ?? -1) + 1);
|
||||
const maxOrder = db
|
||||
.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?')
|
||||
.get(catId) as { max: number | null };
|
||||
const result = db
|
||||
.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)')
|
||||
.run(catId, name.trim(), (maxOrder.max ?? -1) + 1);
|
||||
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) };
|
||||
}
|
||||
|
||||
export function updateTemplateItem(itemId: string, data: { name?: string }) {
|
||||
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId);
|
||||
if (!item) return { error: 'Item not found', status: 404 };
|
||||
if (data.name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId);
|
||||
if (data.name?.trim())
|
||||
db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId);
|
||||
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId) };
|
||||
}
|
||||
|
||||
@@ -672,16 +808,31 @@ export function isAddonEnabled(addonId: string): boolean {
|
||||
|
||||
export function listAddons() {
|
||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||
const providers = db.prepare(`
|
||||
const providers = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, name, description, icon, enabled, sort_order
|
||||
FROM photo_providers
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }>;
|
||||
const fields = db.prepare(`
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon: string;
|
||||
enabled: number;
|
||||
sort_order: number;
|
||||
}>;
|
||||
const fields = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
@@ -701,14 +852,15 @@ export function listAddons() {
|
||||
}
|
||||
|
||||
return [
|
||||
...addons.map(a => ({
|
||||
...addons.map((a) => ({
|
||||
...a,
|
||||
enabled: !!a.enabled,
|
||||
config: a.id === ADDON_IDS.LLM_PARSING
|
||||
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
|
||||
: JSON.parse(a.config || '{}'),
|
||||
config:
|
||||
a.id === ADDON_IDS.LLM_PARSING
|
||||
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
|
||||
: JSON.parse(a.config || '{}'),
|
||||
})),
|
||||
...providers.map(p => ({
|
||||
...providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
@@ -716,7 +868,7 @@ export function listAddons() {
|
||||
icon: p.icon,
|
||||
enabled: !!p.enabled,
|
||||
config: getPhotoProviderConfig(p.id),
|
||||
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
||||
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
|
||||
key: f.field_key,
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
@@ -734,44 +886,52 @@ export function listAddons() {
|
||||
|
||||
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
||||
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
||||
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
|
||||
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as
|
||||
| { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }
|
||||
| undefined;
|
||||
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
|
||||
|
||||
if (addon) {
|
||||
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
if (data.enabled !== undefined)
|
||||
db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
if (data.config !== undefined) {
|
||||
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
|
||||
// the stored key when the client echoes the mask sentinel (see llmConfig.ts).
|
||||
const configToStore = id === ADDON_IDS.LLM_PARSING
|
||||
? prepareLlmAddonConfigForWrite(data.config, JSON.parse(addon.config || '{}'))
|
||||
: data.config;
|
||||
const configToStore =
|
||||
id === ADDON_IDS.LLM_PARSING
|
||||
? prepareLlmAddonConfigForWrite(data.config, JSON.parse(addon.config || '{}'))
|
||||
: data.config;
|
||||
db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(configToStore), id);
|
||||
}
|
||||
} else {
|
||||
if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
if (data.enabled !== undefined)
|
||||
db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
}
|
||||
|
||||
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
||||
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
|
||||
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as
|
||||
| { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }
|
||||
| undefined;
|
||||
const updated = updatedAddon
|
||||
? {
|
||||
...updatedAddon,
|
||||
enabled: !!updatedAddon.enabled,
|
||||
config: updatedAddon.id === ADDON_IDS.LLM_PARSING
|
||||
? maskLlmAddonConfig(JSON.parse(updatedAddon.config || '{}'))
|
||||
: JSON.parse(updatedAddon.config || '{}'),
|
||||
}
|
||||
...updatedAddon,
|
||||
enabled: !!updatedAddon.enabled,
|
||||
config:
|
||||
updatedAddon.id === ADDON_IDS.LLM_PARSING
|
||||
? maskLlmAddonConfig(JSON.parse(updatedAddon.config || '{}'))
|
||||
: JSON.parse(updatedAddon.config || '{}'),
|
||||
}
|
||||
: updatedProvider
|
||||
? {
|
||||
id: updatedProvider.id,
|
||||
name: updatedProvider.name,
|
||||
description: updatedProvider.description,
|
||||
type: 'photo_provider',
|
||||
icon: updatedProvider.icon,
|
||||
enabled: !!updatedProvider.enabled,
|
||||
config: getPhotoProviderConfig(updatedProvider.id),
|
||||
sort_order: updatedProvider.sort_order,
|
||||
}
|
||||
id: updatedProvider.id,
|
||||
name: updatedProvider.name,
|
||||
description: updatedProvider.description,
|
||||
type: 'photo_provider',
|
||||
icon: updatedProvider.icon,
|
||||
enabled: !!updatedProvider.enabled,
|
||||
config: getPhotoProviderConfig(updatedProvider.id),
|
||||
sort_order: updatedProvider.sort_order,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Only these addons gate MCP tool/resource/prompt registration (see
|
||||
@@ -779,31 +939,45 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
|
||||
// what a session would register. Config-only saves, photo providers and
|
||||
// MCP-irrelevant addons must not tear down every live session (#1414).
|
||||
const MCP_RELEVANT_ADDONS = new Set<string>([
|
||||
ADDON_IDS.MCP, ADDON_IDS.PACKING, ADDON_IDS.BUDGET, ADDON_IDS.COLLAB,
|
||||
ADDON_IDS.ATLAS, ADDON_IDS.VACAY, ADDON_IDS.JOURNEY,
|
||||
ADDON_IDS.MCP,
|
||||
ADDON_IDS.PACKING,
|
||||
ADDON_IDS.BUDGET,
|
||||
ADDON_IDS.COLLAB,
|
||||
ADDON_IDS.ATLAS,
|
||||
ADDON_IDS.VACAY,
|
||||
ADDON_IDS.JOURNEY,
|
||||
]);
|
||||
const enabledChanged = !!addon && data.enabled !== undefined && (data.enabled ? 1 : 0) !== addon.enabled;
|
||||
|
||||
return {
|
||||
addon: updated,
|
||||
mcpAffected: enabledChanged && MCP_RELEVANT_ADDONS.has(id),
|
||||
auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined },
|
||||
auditDetails: {
|
||||
enabled: data.enabled !== undefined ? !!data.enabled : undefined,
|
||||
config_changed: data.config !== undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── MCP Tokens ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function listMcpTokens() {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
|
||||
FROM mcp_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
ORDER BY t.created_at DESC
|
||||
`).all();
|
||||
`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function deleteMcpToken(id: string) {
|
||||
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as { id: number; user_id: number } | undefined;
|
||||
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as
|
||||
| { id: number; user_id: number }
|
||||
| undefined;
|
||||
if (!token) return { error: 'Token not found', status: 404 };
|
||||
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
|
||||
revokeUserSessions(token.user_id);
|
||||
@@ -813,7 +987,9 @@ export function deleteMcpToken(id: string) {
|
||||
// ── OAuth Sessions ─────────────────────────────────────────────────────────
|
||||
|
||||
export function listOAuthSessions() {
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
|
||||
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
|
||||
FROM oauth_tokens ot
|
||||
@@ -822,12 +998,16 @@ export function listOAuthSessions() {
|
||||
WHERE ot.revoked_at IS NULL
|
||||
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY ot.created_at DESC
|
||||
`).all() as (Record<string, unknown> & { scopes: string })[];
|
||||
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes) }));
|
||||
`,
|
||||
)
|
||||
.all() as (Record<string, unknown> & { scopes: string })[];
|
||||
return rows.map((r) => ({ ...r, scopes: JSON.parse(r.scopes) }));
|
||||
}
|
||||
|
||||
export function revokeOAuthSession(id: string) {
|
||||
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as { id: number; user_id: number; client_id: string } | undefined;
|
||||
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as
|
||||
| { id: number; user_id: number; client_id: string }
|
||||
| undefined;
|
||||
if (!row) return { error: 'Session not found', status: 404 };
|
||||
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
revokeUserSessionsForClient(row.user_id, row.client_id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { existsSync, promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* In-app Help/Wiki content, sourced from the `wiki/**` directory that ships with
|
||||
@@ -13,7 +13,7 @@ import { existsSync, promises as fs } from 'fs';
|
||||
* GitHub directly either way; images are proxied through /api/help/asset.
|
||||
*/
|
||||
|
||||
const REPO = 'mauriceboe/TREK';
|
||||
const REPO = 'iiketrek/TREK';
|
||||
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main/wiki`;
|
||||
const TTL_MS = 60 * 60 * 1000; // remote fallback only: refresh from GitHub at most hourly
|
||||
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
@@ -33,7 +33,9 @@ const WIKI_DIR = process.env.TREK_WIKI_DIR ?? path.join(__dirname, '..', '..', '
|
||||
const useLocalWiki = existsSync(path.join(WIKI_DIR, '_Sidebar.md'));
|
||||
|
||||
if (!useLocalWiki) {
|
||||
console.warn(`[help] wiki not found at ${WIKI_DIR} — falling back to the GitHub wiki (help may not match this version)`);
|
||||
console.warn(
|
||||
`[help] wiki not found at ${WIKI_DIR} — falling back to the GitHub wiki (help may not match this version)`,
|
||||
);
|
||||
}
|
||||
|
||||
export class WikiNotFound extends Error {
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>TREK</Name>
|
||||
<Repository>mauriceboe/trek</Repository>
|
||||
<Registry>https://hub.docker.com/r/mauriceboe/trek</Registry>
|
||||
<Repository>iiketrek/TREK</Repository>
|
||||
<Registry>https://hub.docker.com/r/iiketrek/TREK</Registry>
|
||||
<Branch>
|
||||
<Tag>latest</Tag>
|
||||
<TagDescription>Latest stable release</TagDescription>
|
||||
@@ -14,8 +14,8 @@
|
||||
<Overview>TREK is a self-hosted, real-time collaborative travel planner with interactive maps, budgets, bookings, packing lists, file management, and more. Plan trips together with your group — changes sync instantly across all connected users. Includes OIDC/SSO support, dark mode, PWA, and a modular addon system (Vacay, Atlas, Collab, Budget, Packing).</Overview>
|
||||
<Category>Productivity: Tools:</Category>
|
||||
<WebUI>http://[IP]:[PORT:3000]</WebUI>
|
||||
<TemplateURL>https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml</TemplateURL>
|
||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||
<TemplateURL>https://raw.githubusercontent.com/iiketrek/TREK/main/unraid-template.xml</TemplateURL>
|
||||
<Icon>https://raw.githubusercontent.com/iiketrek/TREK/main/docs/trek-icon.png</Icon>
|
||||
<ExtraParams/>
|
||||
<PostArgs/>
|
||||
<DonateText>Support TREK development</DonateText>
|
||||
|
||||
@@ -21,7 +21,7 @@ Six cards at the top of the tab link to external resources:
|
||||
|
||||
## Release timeline
|
||||
|
||||
Below the support cards, a chronological timeline lists GitHub releases for the `mauriceboe/TREK` repository. Each entry shows:
|
||||
Below the support cards, a chronological timeline lists GitHub releases for the `iiketrek/TREK` repository. Each entry shows:
|
||||
|
||||
- **Version tag** (e.g., `v2.9.14`)
|
||||
- A **Latest** badge on the first (most recent) entry in the displayed list
|
||||
|
||||
@@ -30,7 +30,7 @@ cd TREK
|
||||
Add the original repository as `upstream` so you can pull in future updates:
|
||||
|
||||
```bash
|
||||
git remote add upstream git@github.com:mauriceboe/TREK.git
|
||||
git remote add upstream git@github.com:iiketrek/TREK.git
|
||||
```
|
||||
|
||||
You should now have two remotes:
|
||||
@@ -38,7 +38,7 @@ You should now have two remotes:
|
||||
| Remote | URL | Purpose |
|
||||
|------------|----------------------------------------------|--------------------------------|
|
||||
| `origin` | `git@github.com:your-username/TREK.git` | Your fork — push changes here |
|
||||
| `upstream` | `git@github.com:mauriceboe/TREK.git` | Main repo — pull updates from here |
|
||||
| `upstream` | `git@github.com:iiketrek/TREK.git` | Main repo — pull updates from here |
|
||||
|
||||
---
|
||||
|
||||
@@ -192,7 +192,7 @@ git push origin fix/my-changes
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev` branch. If your PR only modifies files under `wiki/`, it is exempt from branch enforcement and may target any branch.
|
||||
Then open a Pull Request from your fork to `iiketrek/TREK` targeting the `dev` branch. If your PR only modifies files under `wiki/`, it is exempt from branch enforcement and may target any branch.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ The plugin system is **on by default**. The runtime and the Admin → Plugins pa
|
||||
| `TREK_PLUGINS_ENABLED` | Master switch for the plugin system. Enabled unless set to `false` (also accepts `0`, `off`, `no`, case-insensitive). Turning it off is a kill switch — installed plugins stay on disk but nothing runs. | enabled |
|
||||
| `TREK_PLUGINS_DIR` | Directory where installed plugin **code** is stored. Persist it as a volume if you use plugins. | `<data>/plugins` |
|
||||
| `TREK_PLUGINS_DATA_DIR` | Directory for each plugin's own **data** (its private SQLite file). Kept separate from the code tree; persist it as a volume too. | `<data>/plugins-data` |
|
||||
| `TREK_PLUGIN_REGISTRY_URL` | Override the plugin registry index the *Discover* tab browses. Point it at your own fork or mirror of the registry. | `https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json` |
|
||||
| `TREK_PLUGIN_REGISTRY_URL` | Override the plugin registry index the *Discover* tab browses. Point it at your own fork or mirror of the registry. | `https://raw.githubusercontent.com/iiketrek/TREK-Plugins/main/dist/index.json` |
|
||||
| `TREK_PLUGIN_MAX_RSS_MB` | Per-plugin memory ceiling in MB. A plugin process that exceeds it is stopped. | `300` |
|
||||
| `TREK_PLUGIN_PERMISSIONS` | Set to `off` to opt **out** of the Node.js OS-level permission sandbox for plugin child processes (not recommended). Any other value keeps the sandbox on. | `on` |
|
||||
| `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS`| Set to `on` to let a plugin's declared outbound hosts resolve to private/internal addresses (e.g. a service on your LAN). By default connections to private, loopback, link-local and metadata addresses are refused. | off (private egress blocked) |
|
||||
|
||||
@@ -66,15 +66,15 @@ Three tag strategies are available:
|
||||
|
||||
| Tag | Example | Behavior |
|
||||
|---|---|---|
|
||||
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
|
||||
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
|
||||
| Full version | `mauriceboe/trek:3.0.15` | Exact release; never changes |
|
||||
| `latest` | `iiketrek/TREK:latest` | Always the newest release across all major versions |
|
||||
| Major version | `iiketrek/TREK:3` | Latest release pinned to that major version |
|
||||
| Full version | `iiketrek/TREK:3.0.15` | Exact release; never changes |
|
||||
|
||||
The compose file above uses `latest`. To pin, change the `image:` line:
|
||||
|
||||
```yaml
|
||||
image: mauriceboe/trek:3 # track major version 3
|
||||
image: mauriceboe/trek:3.0.15 # pin to exact release
|
||||
image: iiketrek/TREK:3 # track major version 3
|
||||
image: iiketrek/TREK:3.0.15 # pin to exact release
|
||||
```
|
||||
|
||||
## Start TREK
|
||||
|
||||
@@ -12,7 +12,7 @@ docker run -d \
|
||||
-v ./uploads:/app/uploads \
|
||||
-e ENCRYPTION_KEY=<your-32-byte-hex-key> \
|
||||
--restart unless-stopped \
|
||||
mauriceboe/trek:latest
|
||||
iiketrek/TREK:latest
|
||||
```
|
||||
|
||||
`ENCRYPTION_KEY` is strongly recommended but not strictly required. If omitted, a key is auto-generated on first start and persisted to `data/.encryption_key`. Setting it explicitly means you can recreate the container from scratch (e.g. on a new host) without losing access to stored encrypted data (API keys, SMTP credentials, OIDC secrets, MFA secrets).
|
||||
@@ -38,11 +38,11 @@ See [Environment-Variables](Environment-Variables) for the full list.
|
||||
|
||||
| Tag | Example | Behavior |
|
||||
|---|---|---|
|
||||
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
|
||||
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
|
||||
| Full version | `mauriceboe/trek:3.0.15` | Exact release; never changes |
|
||||
| `latest` | `iiketrek/TREK:latest` | Always the newest release across all major versions |
|
||||
| Major version | `iiketrek/TREK:3` | Latest release pinned to that major version |
|
||||
| Full version | `iiketrek/TREK:3.0.15` | Exact release; never changes |
|
||||
|
||||
Replace `mauriceboe/trek:latest` in the run command with your chosen tag to pin to a major version or exact release.
|
||||
Replace `iiketrek/TREK:latest` in the run command with your chosen tag to pin to a major version or exact release.
|
||||
|
||||
## Volume Reference
|
||||
|
||||
@@ -65,7 +65,7 @@ docker run -d \
|
||||
-v trek_uploads:/app/uploads \
|
||||
-e ENCRYPTION_KEY=<your-32-byte-hex-key> \
|
||||
--restart unless-stopped \
|
||||
mauriceboe/trek:latest
|
||||
iiketrek/TREK:latest
|
||||
```
|
||||
|
||||
Docker creates `trek_data` and `trek_uploads` automatically on first run. Named volumes are easier to manage with `docker volume` commands and work better in some NAS or container-management environments.
|
||||
|
||||
@@ -72,7 +72,7 @@ helm install trek trek/trek \
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: mauriceboe/trek
|
||||
repository: iiketrek/TREK
|
||||
# tag: latest # defaults to the chart's appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
|
||||
@@ -40,9 +40,9 @@ Three tag strategies are available:
|
||||
|
||||
| Tag | Example | Behavior |
|
||||
|---|---|---|
|
||||
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
|
||||
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
|
||||
| Full version | `mauriceboe/trek:3.0.15` | Exact release; never changes |
|
||||
| `latest` | `iiketrek/TREK:latest` | Always the newest release across all major versions |
|
||||
| Major version | `iiketrek/TREK:3` | Latest release pinned to that major version |
|
||||
| Full version | `iiketrek/TREK:3.0.15` | Exact release; never changes |
|
||||
|
||||
Use `latest` or a major-version tag (e.g. `3`) if you want automatic updates on redeploy. Use a full version tag (e.g. `3.0.15`) if you want explicit control over which release runs.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Docker must be enabled in Unraid (**Settings → Docker → Enable Docker: Yes**
|
||||
If the app does not appear, you can install directly from the template URL. In **Docker → Add Container**, paste the template URL:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml
|
||||
https://raw.githubusercontent.com/iiketrek/TREK/main/unraid-template.xml
|
||||
```
|
||||
|
||||
## Template Fields
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d \
|
||||
-v ./data:/app/data \
|
||||
-v ./uploads:/app/uploads \
|
||||
--restart unless-stopped \
|
||||
mauriceboe/trek:latest
|
||||
iiketrek/TREK:latest
|
||||
```
|
||||
|
||||
**Flag breakdown:**
|
||||
|
||||
+5
-5
@@ -10,9 +10,9 @@ Back up your data first. Go to Admin Panel → Backups and create a manual backu
|
||||
|
||||
| Tag | Example | Behavior |
|
||||
|---|---|---|
|
||||
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
|
||||
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
|
||||
| Full version | `mauriceboe/trek:3.0.15` | Exact release; never changes |
|
||||
| `latest` | `iiketrek/TREK:latest` | Always the newest release across all major versions |
|
||||
| Major version | `iiketrek/TREK:3` | Latest release pinned to that major version |
|
||||
| Full version | `iiketrek/TREK:3.0.15` | Exact release; never changes |
|
||||
|
||||
Use `latest` or a major-version tag if you want updates on each redeploy. Use a full version tag for explicit control — update by changing the tag, not by re-pulling.
|
||||
|
||||
@@ -39,14 +39,14 @@ docker compose up -d
|
||||
If you started TREK with `docker run`, pull the new image and replace the container:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/trek
|
||||
docker pull iiketrek/TREK
|
||||
docker rm -f trek
|
||||
docker run -d --name trek -p 3000:3000 \
|
||||
-v ./data:/app/data \
|
||||
-v ./uploads:/app/uploads \
|
||||
-e ENCRYPTION_KEY=<your-key> \
|
||||
--restart unless-stopped \
|
||||
mauriceboe/trek
|
||||
iiketrek/TREK
|
||||
```
|
||||
|
||||
> **Tip:** Not sure which volume paths you used? Check before removing:
|
||||
|
||||
Reference in New Issue
Block a user