mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca832e8d88 | |||
| 12fc7f7b68 | |||
| 2770a189df | |||
| 2b162a8cc7 | |||
| 009d89fecf | |||
| 5c3b89578d | |||
| 303e7de433 | |||
| 08eb7f3733 | |||
| 90d86eda61 | |||
| 0eca6d54a1 | |||
| bc1fb71391 | |||
| cb425fb397 | |||
| 35ed712d46 | |||
| 4923973380 | |||
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd | |||
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e | |||
| 523bca3a20 | |||
| d5be528d4b |
@@ -62,6 +62,7 @@ body:
|
||||
- Docker (standalone)
|
||||
- Kubernetes / Helm
|
||||
- Unraid template
|
||||
- Proxmox Community Script
|
||||
- Sources
|
||||
- Other
|
||||
validations:
|
||||
|
||||
@@ -7,7 +7,10 @@ on:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/wiki.yml'
|
||||
- '.github/workflows/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Trademark Policy
|
||||
|
||||
## Introduction
|
||||
|
||||
This is the TREK project's policy for the use of our trademarks. While TREK is
|
||||
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
|
||||
license does not include a license to use our trademarks.
|
||||
|
||||
This policy describes how you may use our trademarks. Our goal is to strike a
|
||||
balance between: 1) our need to ensure that our trademarks remain reliable
|
||||
indicators of the software we release; and 2) our community members' desire to
|
||||
be full participants in the TREK project.
|
||||
|
||||
## Our trademarks
|
||||
|
||||
This policy covers the name "TREK" as well as any associated logos, trade dress,
|
||||
goodwill, or designs (our "Marks").
|
||||
|
||||
## In general
|
||||
|
||||
Whenever you use our Marks, you must always do so in a way that does not mislead
|
||||
anyone about exactly who is the source of the software. For example, you cannot
|
||||
say you are distributing TREK when you're distributing a modified version of it,
|
||||
because people would think they would be getting the same software that they
|
||||
can get directly from us when they aren't. You also cannot use our Marks on
|
||||
your website in a way that suggests that your website is an official TREK
|
||||
website or that we endorse your website. But, if true, you can say you like
|
||||
TREK, that you participate in the TREK community, that you are providing an
|
||||
unmodified version of TREK, or that you wrote a guide describing how to use
|
||||
TREK.
|
||||
|
||||
This fundamental requirement, that it is always clear to people what they are
|
||||
getting and from whom, is reflected throughout this policy. It should also
|
||||
serve as your guide if you are not sure about how you are using the Marks.
|
||||
|
||||
In addition:
|
||||
|
||||
* You may not use or register, in whole or in part, the Marks as part of your
|
||||
own trademark, service mark, domain name, company name, trade name, product
|
||||
name or service name.
|
||||
* Trademark law does not allow your use of names or trademarks that are too
|
||||
similar to ours. You therefore may not use an obvious variation of any of our
|
||||
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
|
||||
abbreviation for a similar or compatible product or service.
|
||||
* You agree that you will not acquire any rights in the Marks and that any
|
||||
goodwill generated by your use of the Marks and participation in our
|
||||
community inures solely to our benefit.
|
||||
|
||||
## Distribution of unmodified source code or unmodified executable code we have compiled
|
||||
|
||||
When you redistribute an unmodified copy of TREK, you are not changing the
|
||||
quality or nature of it. Therefore, you may retain the Marks we have placed on
|
||||
the software to identify your redistribution. This kind of use only applies if
|
||||
you are redistributing an official TREK distribution that has not been changed
|
||||
in any way.
|
||||
|
||||
## Distribution of executable code that you have compiled, or modified code
|
||||
|
||||
You may use the word mark "TREK", but not any TREK logos, to truthfully
|
||||
describe the origin of the software that you are providing, that is, that the
|
||||
code you are distributing is a modification of TREK. You may say, for example,
|
||||
that "this software is derived from the source code for TREK."
|
||||
|
||||
Of course, you can place your own trademarks or logos on versions of the
|
||||
software to which you have made substantive modifications, because by modifying
|
||||
the software, you have become the origin of that exact version. In that case,
|
||||
you should not use our Marks.
|
||||
|
||||
However, you may use our Marks for the distribution of code (source or
|
||||
executable) on the condition that any executable is built from an official TREK
|
||||
source code release and that any modifications are limited to switching on or
|
||||
off features already included in the software, translations into other
|
||||
languages, and incorporating minor bug-fix patches. Use of our Marks on any
|
||||
further modification is not permitted.
|
||||
|
||||
## Mobile wrappers, hosted instances, and forks
|
||||
|
||||
The following clarifications apply specifically to common ways TREK is
|
||||
redistributed:
|
||||
|
||||
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
|
||||
as "a TREK instance" or "running TREK." You may not name the service itself
|
||||
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
|
||||
Official," etc.).
|
||||
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
|
||||
TREK.** You may describe your app as "a mobile client for TREK" or "for use
|
||||
with TREK." You may not publish it on app stores under the name "TREK" or a
|
||||
confusingly similar name, and you may not use the TREK logo as the app icon
|
||||
unless your wrapper distributes only an unmodified, official TREK instance
|
||||
and you have obtained permission.
|
||||
* **Forks of the TREK source code.** Forks that diverge from upstream must use
|
||||
a different name. You may state that your fork is "based on TREK" or "a fork
|
||||
of TREK," but the project name itself must be your own.
|
||||
|
||||
## Statements about your software's relation to TREK
|
||||
|
||||
You may use the word mark, but not TREK logos, to truthfully describe the
|
||||
relationship between your software and ours. The word mark "TREK" should be
|
||||
used after a verb or preposition that describes the relationship between your
|
||||
software and ours. So you may say, for example, "Bob's app for TREK" but may
|
||||
not say "Bob's TREK app." Some other examples that may work for you are:
|
||||
|
||||
* [Your software] uses TREK
|
||||
* [Your software] is powered by TREK
|
||||
* [Your software] runs on TREK
|
||||
* [Your software] for use with TREK
|
||||
* [Your software] for TREK
|
||||
|
||||
## Questions and permission requests
|
||||
|
||||
If you are not sure whether your intended use of the Marks is permitted under
|
||||
this policy, or if you would like to request explicit permission for a use that
|
||||
is not covered, please open an issue on the TREK GitHub repository or contact
|
||||
the maintainers directly.
|
||||
|
||||
---
|
||||
|
||||
These guidelines are based on the
|
||||
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
|
||||
under a
|
||||
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.8
|
||||
version: 3.0.10
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.8"
|
||||
appVersion: "3.0.10"
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.8",
|
||||
"version": "3.0.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "3.0.8",
|
||||
"version": "3.0.10",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
@@ -8907,9 +8907,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.8",
|
||||
"version": "3.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 16, overflow: 'auto',
|
||||
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
overflow: 'auto',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}} onClick={() => setDismissed(true)}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||
maxWidth: 480, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: '90vh', overflow: 'auto',
|
||||
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||
overflow: 'auto',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
@@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
||||
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
position: 'sticky', bottom: 0, background: 'white',
|
||||
marginTop: 'auto',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||
<Github size={13} />
|
||||
|
||||
@@ -24,10 +24,6 @@ const mockDayNotesState = vi.hoisted(() => ({
|
||||
moveNote: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockPermissionsState = vi.hoisted(() => ({
|
||||
canDo: true,
|
||||
}))
|
||||
|
||||
// ── Module mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
@@ -83,7 +79,7 @@ vi.mock('../../store/permissionsStore', async (importOriginal) => {
|
||||
const actual = await importOriginal() as any
|
||||
return {
|
||||
...actual,
|
||||
useCanDo: () => () => mockPermissionsState.canDo,
|
||||
useCanDo: () => () => true,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,7 +125,6 @@ beforeEach(() => {
|
||||
// Reset mutable day-notes state
|
||||
mockDayNotesState.noteUi = {}
|
||||
mockDayNotesState.dayNotes = {}
|
||||
mockPermissionsState.canDo = true
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as any)
|
||||
@@ -899,136 +894,21 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── ICS export click ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-058: clicking ICS button first asks link or download', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => {
|
||||
const user = userEvent.setup()
|
||||
if (!navigator.clipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = String(input)
|
||||
const method = (init?.method || 'GET').toUpperCase()
|
||||
|
||||
if (url === '/api/trips/1/subscribe.ics' && method === 'GET') {
|
||||
return {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: null }),
|
||||
} as any
|
||||
}
|
||||
|
||||
if (url === '/api/trips/1/subscribe.ics' && method === 'POST') {
|
||||
return {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
url: 'https://example.com/api/shared/token/calendar.ics',
|
||||
webcal_url: 'webcal://example.com/api/shared/token/calendar.ics',
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
if (url === '/api/trips/1/subscribe.ics' && method === 'DELETE') {
|
||||
return { ok: true } as any
|
||||
}
|
||||
|
||||
if (url === '/api/trips/1/export.ics' && method === 'GET') {
|
||||
return {
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
||||
} as any
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch call: ${method} ${url}`)
|
||||
})
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
await user.click(screen.getByText('ICS').closest('button')!)
|
||||
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
|
||||
expect(await screen.findByText('Calendar share')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create a subscription link for calendar apps, or download the ICS file.')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create link' }))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' })))
|
||||
|
||||
expect(screen.getByDisplayValue('https://example.com/api/shared/token/calendar.ics')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Copy' }))
|
||||
await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith('https://example.com/api/shared/token/calendar.ics'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete link' }))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'DELETE' })))
|
||||
expect(screen.getByRole('button', { name: 'Create link' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Download ICS file' }))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
expect(createObjURL).toHaveBeenCalled()
|
||||
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
|
||||
|
||||
fetchSpy.mockRestore()
|
||||
clipboardSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
revokeObjURL.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-097: opening ICS dialog shows existing generated link when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const expectedUrl = `${window.location.origin}/api/shared/existing-token/calendar.ics`
|
||||
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: 'existing-token' }),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
||||
} as any)
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
await user.click(screen.getByText('ICS').closest('button')!)
|
||||
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
|
||||
expect(await screen.findByDisplayValue(expectedUrl)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Create link' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Copy' }))
|
||||
await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith(expectedUrl))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Download ICS file' }))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
expect(createObjURL).toHaveBeenCalled()
|
||||
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
|
||||
expect(fetchSpy).not.toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' }))
|
||||
|
||||
fetchSpy.mockRestore()
|
||||
clipboardSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
revokeObjURL.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-099: ICS dialog hides delete link button without share_manage permission', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPermissionsState.canDo = false
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: 'existing-token' }),
|
||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
||||
} as any)
|
||||
|
||||
// Mock URL.createObjectURL
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
await user.click(screen.getByText('ICS').closest('button')!)
|
||||
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
|
||||
expect(await screen.findByDisplayValue(`${window.location.origin}/api/shared/existing-token/calendar.ics`)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete link' })).not.toBeInTheDocument()
|
||||
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
fetchSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
revokeObjURL.mockRestore()
|
||||
})
|
||||
|
||||
// ── openAddNote button click ──────────────────────────────────────────
|
||||
|
||||
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon, Link2, Copy } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
@@ -225,7 +225,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const tripActions = useRef(useTripStore.getState()).current
|
||||
const can = useCanDo()
|
||||
const canEditDays = can('day_edit', trip)
|
||||
const canManageShare = can('share_manage', trip)
|
||||
|
||||
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||
|
||||
@@ -253,9 +252,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [icsDialog, setIcsDialog] = useState<{ url: string; webcal_url: string; creating: boolean } | null>(null)
|
||||
const [icsCopied, setIcsCopied] = useState(false)
|
||||
const icsCopyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -288,99 +284,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closeIcsDialog = () => setIcsDialog(null)
|
||||
|
||||
const handleIcsOpenDialog = async () => {
|
||||
setIcsCopied(false)
|
||||
setIcsDialog({ url: '', webcal_url: '', creating: true })
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
const data = await res.json() as { token?: string | null }
|
||||
if (data.token) {
|
||||
const url = `${window.location.origin}/api/shared/${encodeURIComponent(data.token)}/calendar.ics`
|
||||
const webcal_url = url.replace(/^https?:\/\//, 'webcal://')
|
||||
setIcsDialog({ url, webcal_url, creating: false })
|
||||
} else {
|
||||
setIcsDialog({ url: '', webcal_url: '', creating: false })
|
||||
}
|
||||
} catch {
|
||||
setIcsDialog({ url: '', webcal_url: '', creating: false })
|
||||
toast.error(t('dayplan.calendarLinkFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIcsCreateLink = async () => {
|
||||
if (icsDialog?.creating) return
|
||||
setIcsDialog(prev => prev ? { ...prev, creating: true } : { url: '', webcal_url: '', creating: true })
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { method: 'POST', credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
const data = await res.json() as { url?: string; webcal_url?: string }
|
||||
const shareUrl = data.url
|
||||
const openUrl = data.webcal_url || data.url
|
||||
if (!shareUrl || !openUrl) throw new Error()
|
||||
setIcsDialog({ url: shareUrl, webcal_url: openUrl, creating: false })
|
||||
} catch {
|
||||
setIcsDialog(prev => prev ? { ...prev, creating: false } : prev)
|
||||
toast.error(t('dayplan.calendarLinkFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIcsCopyLink = async () => {
|
||||
if (!icsDialog?.url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(icsDialog.url)
|
||||
setIcsCopied(true)
|
||||
if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current)
|
||||
icsCopyTimerRef.current = setTimeout(() => setIcsCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error(t('dayplan.calendarCopyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIcsDeleteLink = async () => {
|
||||
if (!icsDialog || icsDialog.creating) return
|
||||
setIcsDialog(prev => prev ? { ...prev, creating: true } : prev)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setIcsCopied(false)
|
||||
setIcsDialog(prev => prev ? { ...prev, url: '', webcal_url: '', creating: false } : prev)
|
||||
toast.success(t('dayplan.calendarLinkDeleted'))
|
||||
} catch {
|
||||
setIcsDialog(prev => prev ? { ...prev, creating: false } : prev)
|
||||
toast.error(t('dayplan.calendarDeleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIcsDownload = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(t('dayplan.calendarDownloaded'))
|
||||
closeIcsDialog()
|
||||
} catch {
|
||||
toast.error(t('dayplan.calendarExportFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
|
||||
const getDragData = (e) => {
|
||||
const dt = e?.dataTransfer
|
||||
@@ -1090,7 +993,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={handleIcsOpenDialog}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||
}}
|
||||
onMouseEnter={() => setIcsHover(true)}
|
||||
onMouseLeave={() => setIcsHover(false)}
|
||||
style={{
|
||||
@@ -2211,105 +2128,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* ICS subscription dialog */}
|
||||
{icsDialog && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={closeIcsDialog}>
|
||||
<div style={{
|
||||
width: 420, maxWidth: '92vw', background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12, position: 'relative',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={closeIcsDialog}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{t('dayplan.calendarShareTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
|
||||
{t('dayplan.calendarShareDescription')}
|
||||
</p>
|
||||
{icsDialog.url ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
|
||||
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<input type="text" value={icsDialog.url} readOnly style={{
|
||||
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
|
||||
outline: 'none', fontFamily: 'monospace',
|
||||
}} />
|
||||
<button onClick={handleIcsCopyLink} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||
border: 'none', background: icsCopied ? '#16a34a' : 'var(--accent)', color: icsCopied ? 'white' : 'var(--accent-text)',
|
||||
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||
}}>
|
||||
{icsCopied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||
</button>
|
||||
</div>
|
||||
{canManageShare && (
|
||||
<button onClick={handleIcsDeleteLink} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Trash2 size={11} /> {t('dayplan.calendarDeleteLink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleIcsCreateLink}
|
||||
disabled={!canManageShare}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{canManageShare ? <><Link2 size={12} /> {t('dayplan.calendarCreateLink')}</> : <>{t('dayplan.calendarCreateLinkNoPermission')}</>}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleIcsDownload}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8, border: 'none',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('dayplan.calendarDownloadFile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Transport-Detail-Modal */}
|
||||
{transportDetail && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
|
||||
@@ -305,18 +305,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notificationsActive': 'Active channel',
|
||||
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
|
||||
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
||||
'dayplan.calendarShareTitle': 'Calendar share',
|
||||
'dayplan.calendarShareDescription': 'Create a subscription link for calendar apps, or download the ICS file.',
|
||||
'dayplan.calendarCreateLink': 'Create link',
|
||||
'dayplan.calendarCreateLinkNoPermission': 'You do not have permission to create calendar links for this trip.',
|
||||
'dayplan.calendarDeleteLink': 'Delete link',
|
||||
'dayplan.calendarDownloadFile': 'Download ICS file',
|
||||
'dayplan.calendarLinkFailed': 'Calendar link failed',
|
||||
'dayplan.calendarDeleteFailed': 'Delete link failed',
|
||||
'dayplan.calendarCopyFailed': 'Copy failed',
|
||||
'dayplan.calendarDownloaded': 'ICS downloaded',
|
||||
'dayplan.calendarExportFailed': 'ICS export failed',
|
||||
'dayplan.calendarLinkDeleted': 'Calendar link deleted',
|
||||
'share.linkTitle': 'Public Link',
|
||||
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
||||
'share.createLink': 'Create link',
|
||||
|
||||
Generated
+888
-529
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.8",
|
||||
"version": "3.0.10",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
@@ -23,6 +23,7 @@
|
||||
"express": "^4.18.3",
|
||||
"fast-xml-parser": "^5.5.10",
|
||||
"helmet": "^8.1.0",
|
||||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -30,7 +31,6 @@
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "^7.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
|
||||
@@ -2130,22 +2130,6 @@ function runMigrations(db: Database.Database): void {
|
||||
'ON journey_entries(journey_id, entry_date, sort_order)'
|
||||
);
|
||||
},
|
||||
() => {
|
||||
// Dedicated calendar subscription tokens for trips
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_share_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_by INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id),
|
||||
UNIQUE(trip_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_calendar_share_trip ON calendar_share_tokens(trip_id)');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -202,15 +202,6 @@ function createTables(db: Database.Database): void {
|
||||
UNIQUE(trip_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_share_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS day_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -58,16 +58,4 @@ router.get('/shared/:token', (req: Request, res: Response) => {
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
// Public calendar subscription payload (no auth required)
|
||||
router.get('/shared/:token/calendar.ics', (req: Request, res: Response) => {
|
||||
const { token } = req.params;
|
||||
const exported = shareService.getSharedTripICS(token);
|
||||
if (!exported) return res.status(404).json({ error: 'Invalid or expired link' });
|
||||
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
// Inline lets calendar clients subscribe/fetch from URL instead of forced download.
|
||||
res.setHeader('Content-Disposition', `inline; filename="${exported.filename}"`);
|
||||
res.send(exported.ics);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -36,7 +36,6 @@ import { listItems as listTodoItems } from '../services/todoService';
|
||||
import { listBudgetItems } from '../services/budgetService';
|
||||
import { listReservations } from '../services/reservationService';
|
||||
import { listFiles } from '../services/fileService';
|
||||
import { createOrUpdateCalendarShareLink, getCalendarShareLink, deleteCalendarShareLink } from '../services/shareService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -356,64 +355,4 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── ICS calendar subscription link ───────────────────────────────────────
|
||||
|
||||
router.get('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.id, authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
}
|
||||
|
||||
const existing = getCalendarShareLink(req.params.id);
|
||||
const token = existing?.token ?? null;
|
||||
|
||||
const host = req.get('host');
|
||||
if (!host) return res.status(500).json({ error: 'Host header missing' });
|
||||
|
||||
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
||||
? req.headers['x-forwarded-proto'].split(',')[0].trim()
|
||||
: null;
|
||||
const protocol = forwardedProto || req.protocol;
|
||||
const url = token ? `${protocol}://${host}/api/shared/${encodeURIComponent(token)}/calendar.ics` : null;
|
||||
const webcal_url = url ? url.replace(/^https?:\/\//, 'webcal://') : null;
|
||||
|
||||
res.json({ url, webcal_url, token, created: false });
|
||||
});
|
||||
|
||||
router.post('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
}
|
||||
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
|
||||
const result = createOrUpdateCalendarShareLink(req.params.id, authReq.user.id);
|
||||
const host = req.get('host');
|
||||
if (!host) return res.status(500).json({ error: 'Host header missing' });
|
||||
|
||||
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
||||
? req.headers['x-forwarded-proto'].split(',')[0].trim()
|
||||
: null;
|
||||
const protocol = forwardedProto || req.protocol;
|
||||
const url = `${protocol}://${host}/api/shared/${encodeURIComponent(result.token)}/calendar.ics`;
|
||||
const webcal_url = url.replace(/^https?:\/\//, 'webcal://');
|
||||
|
||||
res.status(result.created ? 201 : 200).json({ url, webcal_url, token: result.token, created: result.created });
|
||||
});
|
||||
|
||||
router.delete('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
}
|
||||
|
||||
deleteCalendarShareLink(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import sharp from 'sharp'
|
||||
import { Jimp } from 'jimp'
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import crypto from 'crypto'
|
||||
import { isAddonEnabled } from '../adminService'
|
||||
import { ADDON_IDS } from '../../addons'
|
||||
|
||||
const THUMB_MAX = 800
|
||||
const THUMB_QUALITY = 80
|
||||
@@ -10,12 +12,14 @@ export async function ensureLocalThumbnail(
|
||||
uploadsRoot: string,
|
||||
originalRelPath: string,
|
||||
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
|
||||
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return null
|
||||
|
||||
const originalAbs = path.join(uploadsRoot, originalRelPath)
|
||||
try { await fs.access(originalAbs) } catch { return null }
|
||||
|
||||
// Deterministic name so concurrent requests don't race on the same photo.
|
||||
const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
|
||||
const thumbRel = `journey/thumbs/${hash}.webp`
|
||||
const thumbRel = `journey/thumbs/${hash}.jpg`
|
||||
const thumbAbs = path.join(uploadsRoot, thumbRel)
|
||||
|
||||
try {
|
||||
@@ -24,18 +28,21 @@ export async function ensureLocalThumbnail(
|
||||
fs.stat(thumbAbs).catch(() => null),
|
||||
])
|
||||
if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
|
||||
const meta = await sharp(thumbAbs).metadata()
|
||||
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
||||
const img = await Jimp.read(thumbAbs)
|
||||
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
|
||||
await sharp(originalAbs)
|
||||
.rotate()
|
||||
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: THUMB_QUALITY })
|
||||
.toFile(thumbAbs)
|
||||
const meta = await sharp(thumbAbs).metadata()
|
||||
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
||||
|
||||
// Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior.
|
||||
const img = await Jimp.read(originalAbs)
|
||||
const { width: w, height: h } = img.bitmap
|
||||
if (w > THUMB_MAX || h > THUMB_MAX) {
|
||||
img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX })
|
||||
}
|
||||
await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY })
|
||||
|
||||
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
|
||||
} catch {
|
||||
// Unsupported format, corrupt file, etc. — fall back to original in caller.
|
||||
return null
|
||||
|
||||
@@ -61,16 +61,24 @@ function resolveDayIdFromTime(
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
|
||||
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
endpoints.forEach((e, i) => {
|
||||
insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||
function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
|
||||
// Bind the transaction lazily on each call. Binding at module load time
|
||||
// captures the DB connection that was open then, which becomes invalid
|
||||
// after demo-reset / restore-from-backup closes and reinitialises the
|
||||
// connection — every later endpoint save would throw
|
||||
// "The database connection is not open".
|
||||
const tx = db.transaction((rid: number, eps: EndpointInput[]) => {
|
||||
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(rid);
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
eps.forEach((e, i) => {
|
||||
insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||
});
|
||||
});
|
||||
});
|
||||
tx(reservationId, endpoints);
|
||||
}
|
||||
|
||||
export function listReservations(tripId: string | number) {
|
||||
const reservations = db.prepare(`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import crypto from 'crypto';
|
||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||
import { exportICS } from './tripService';
|
||||
|
||||
interface SharePermissions {
|
||||
share_map?: boolean;
|
||||
@@ -21,11 +20,6 @@ interface ShareTokenInfo {
|
||||
share_collab: boolean;
|
||||
}
|
||||
|
||||
interface CalendarShareTokenInfo {
|
||||
token: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new share link or updates the permissions on an existing one.
|
||||
* Returns an object with the token string and whether it was newly created.
|
||||
@@ -85,57 +79,6 @@ export function deleteShareLink(tripId: string): void {
|
||||
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or returns a dedicated calendar subscription link for a trip.
|
||||
*/
|
||||
export function createOrUpdateCalendarShareLink(
|
||||
tripId: string,
|
||||
createdBy: number,
|
||||
): { token: string; created: boolean } {
|
||||
const existing = db.prepare('SELECT token FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
|
||||
if (existing) {
|
||||
return { token: existing.token, created: false };
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
db.prepare('INSERT INTO calendar_share_tokens (trip_id, token, created_by) VALUES (?, ?, ?)')
|
||||
.run(tripId, token, createdBy);
|
||||
return { token, created: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the calendar subscription link for a trip, or null if none exists.
|
||||
*/
|
||||
export function getCalendarShareLink(tripId: string): CalendarShareTokenInfo | null {
|
||||
const row = db.prepare('SELECT * FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as any;
|
||||
if (!row) return null;
|
||||
return {
|
||||
token: row.token,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the calendar subscription link for a trip.
|
||||
*/
|
||||
export function deleteCalendarShareLink(tripId: string): void {
|
||||
db.prepare('DELETE FROM calendar_share_tokens WHERE trip_id = ?').run(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a shared token to ICS calendar content.
|
||||
* Returns null when token or trip is invalid.
|
||||
*/
|
||||
export function getSharedTripICS(token: string): { ics: string; filename: string } | null {
|
||||
const shareRow = db.prepare('SELECT trip_id FROM calendar_share_tokens WHERE token = ?').get(token) as { trip_id: number } | undefined;
|
||||
if (!shareRow) return null;
|
||||
try {
|
||||
return exportICS(shareRow.trip_id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the full public trip data for a share token, filtered by the token's
|
||||
* permission flags. Returns null if the token is invalid or the trip is gone.
|
||||
|
||||
@@ -204,24 +204,6 @@ describe('Shared trip access', () => {
|
||||
.send({});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('SHARE-009 — GET /shared/:token/calendar.ics returns public calendar payload', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Rome Calendar' });
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Host', 'trek.example.com')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}/calendar.ics`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/calendar/);
|
||||
expect(res.text).toContain('BEGIN:VCALENDAR');
|
||||
expect(res.text).toContain('END:VCALENDAR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shared trip — day assignments and notes', () => {
|
||||
|
||||
@@ -855,104 +855,6 @@ describe('ICS export', () => {
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('TRIP-025 — GET /api/trips/:id/subscribe.ics returns null before a calendar link exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Host', 'trek.example.com')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.url).toBeNull();
|
||||
expect(res.body.webcal_url).toBeNull();
|
||||
expect(res.body.token).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-025 — POST /api/trips/:id/subscribe.ics creates shareable http+webcal links', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Host', 'trek.example.com')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.url).toMatch(/^http:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/);
|
||||
expect(res.body.webcal_url).toMatch(/^webcal:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
});
|
||||
|
||||
|
||||
it('TRIP-025 — DELETE /api/trips/:id/subscribe.ics removes calendar token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Delete Calendar Token' });
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Host', 'trek.example.com')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const status = await request(app)
|
||||
.get(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Host', 'trek.example.com')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(status.body.token).toBeNull();
|
||||
|
||||
});
|
||||
|
||||
it('TRIP-025 — non-member cannot get subscribe link → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-025 — member without share_manage cannot create subscribe link → 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Host', 'trek.example.com')
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('TRIP-025 — member without share_manage cannot delete subscribe link → 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Host', 'trek.example.com')
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/subscribe.ics`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Install: Proxmox VE (LXC)
|
||||
|
||||
Install TREK on Proxmox VE as an LXC container using the [Proxmox VE Community Scripts](https://community-scripts.org/scripts/trek).
|
||||
|
||||
> A big thank you to the members of [community-scripts](https://github.com/community-scripts) for adding TREK to their collection and maintaining the install and update scripts.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Proxmox VE with shell access
|
||||
- Internet access from the Proxmox host
|
||||
|
||||
## Install
|
||||
|
||||
Run the following command in the **Proxmox VE Shell**:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/trek.sh)"
|
||||
```
|
||||
|
||||
> **Tip:** Always verify the latest command on the [community-scripts TREK page](https://community-scripts.org/scripts/trek) before running — the script URL may change between releases.
|
||||
|
||||
The script will prompt you to choose between **Default** and **Advanced** settings.
|
||||
|
||||
### Default container specs
|
||||
|
||||
| Resource | Value |
|
||||
|---|---|
|
||||
| OS | Debian 13 |
|
||||
| CPU | 2 cores |
|
||||
| RAM | 2048 MB |
|
||||
| Storage | 8 GB |
|
||||
| Port | 3000 |
|
||||
|
||||
The container is unprivileged. TREK is installed at `/opt/trek`.
|
||||
|
||||
## After Install
|
||||
|
||||
Once the container starts, open your browser at:
|
||||
|
||||
```
|
||||
http://<container-ip>:3000
|
||||
```
|
||||
|
||||
On first boot, TREK automatically creates an admin account. The credentials are printed to the container log — check them with:
|
||||
|
||||
```bash
|
||||
journalctl -u trek -n 50
|
||||
```
|
||||
|
||||
The `ENCRYPTION_KEY` is auto-generated during setup and saved to `/opt/trek/server/.env`. Record that file in your backups.
|
||||
|
||||
## Viewing Logs
|
||||
|
||||
TREK runs as a systemd service named `trek` inside the LXC. To view logs from within the container:
|
||||
|
||||
```bash
|
||||
# Follow live logs
|
||||
journalctl -u trek -f
|
||||
|
||||
# Show last 100 lines
|
||||
journalctl -u trek -n 100
|
||||
|
||||
# Show logs since last boot
|
||||
journalctl -u trek -b
|
||||
```
|
||||
|
||||
To access the container shell from the Proxmox VE host, click the container in the UI and open **Console**, or run:
|
||||
|
||||
```bash
|
||||
pct enter <container-id>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The environment file is located at `/opt/trek/server/.env` inside the container. Edit it to set variables like `ALLOWED_ORIGINS`, `APP_URL`, or `TZ`, then restart the service:
|
||||
|
||||
```bash
|
||||
systemctl restart trek
|
||||
```
|
||||
|
||||
See [Environment-Variables](Environment-Variables) for the full variable reference.
|
||||
|
||||
## Updating
|
||||
|
||||
Run the following command inside the **LXC container** and select **Update** when prompted:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/trek.sh)"
|
||||
```
|
||||
|
||||
> **Tip:** Always check the [community-scripts TREK page](https://community-scripts.org/scripts/trek) to confirm the latest command before running.
|
||||
|
||||
The script stops the service, backs up your data and uploads, applies the new release, restores the backup, and restarts. No manual steps required.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Environment-Variables](Environment-Variables) — complete variable reference
|
||||
- [Reverse-Proxy](Reverse-Proxy) — put TREK behind Nginx or Caddy
|
||||
- [Updating](Updating) — general update notes
|
||||
@@ -223,6 +223,23 @@ If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default).
|
||||
|
||||
---
|
||||
|
||||
## MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts
|
||||
|
||||
**Cause:** TREK builds the OAuth 2.1 redirect URI from `APP_URL`. If `APP_URL` is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes.
|
||||
|
||||
**Fix:** Set `APP_URL` to the public URL of your instance:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- APP_URL=https://trek.example.com
|
||||
```
|
||||
|
||||
Restart the container after adding the variable. Once set, clicking **Connect** in the MCP client should redirect to your TREK instance and complete the OAuth flow normally.
|
||||
|
||||
> **Note:** `APP_URL` is required for any MCP OAuth integration. Without it, the authorization endpoint resolves to `http://localhost:<PORT>`, which is unreachable from external MCP clients.
|
||||
|
||||
---
|
||||
|
||||
## MCP integration: "Too many requests" or "Session limit reached"
|
||||
|
||||
**Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response.
|
||||
|
||||
@@ -44,6 +44,25 @@ If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY`
|
||||
|
||||
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure.
|
||||
|
||||
## Proxmox VE (LXC)
|
||||
|
||||
If you installed TREK via the [Proxmox VE Community Scripts](https://community-scripts.org/scripts/trek), run the following command inside the **LXC container** and select **Update** when prompted:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/trek.sh)"
|
||||
```
|
||||
|
||||
> **Tip:** Always check the [community-scripts TREK page](https://community-scripts.org/scripts/trek) to confirm the latest command before running.
|
||||
|
||||
The script stops the service, backs up your data and uploads, applies the new release, restores the backup, and restarts. No manual steps required.
|
||||
|
||||
To verify the update completed and check for errors:
|
||||
|
||||
```bash
|
||||
# Inside the container (pct enter <id> from the Proxmox shell)
|
||||
journalctl -u trek -n 50
|
||||
```
|
||||
|
||||
## Unraid
|
||||
|
||||
In the Unraid Docker tab, click the TREK container and select **Update**. Unraid will pull the latest image and restart with the same volumes.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
- [[Install: Docker|Install-Docker]]
|
||||
- [[Install: Docker Compose|Install-Docker-Compose]]
|
||||
- [[Install: Helm|Install-Helm]]
|
||||
- [[Install: Proxmox VE (LXC)|Install-Proxmox]]
|
||||
- [[Install: Unraid|Install-Unraid]]
|
||||
- [[Reverse Proxy|Reverse-Proxy]]
|
||||
- [[Environment Variables|Environment-Variables]]
|
||||
|
||||
Reference in New Issue
Block a user