Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ RUN npm run build

# --- Runtime stage ---
FROM node:22-alpine AS runtime
RUN apk add --no-cache openssl
RUN apk add --no-cache openssl cifs-utils util-linux
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ TeslaCam Viewer is a self hosted web app for browsing Tesla dashcam clips and pl
- TeslaCam clip browsing and timeline playback
- Four camera synchronized player (`front`, `back`, `left_repeater`, `right_repeater`)
- SFTP upload workflow
- Multi vehicle folder support (`MX_Name`, `MY_Name`, `MS_Name`, `M3_Name`)
- Multi vehicle folder support (`MX_Name`, `MY_Name`, `MS_Name`, `M3_Name`, `CT_Name`)
- Authentication with password, passkey, and TOTP
- LAN friendly hostname publishing with `teslacam.local`

Expand Down Expand Up @@ -150,6 +150,7 @@ Generated `config.env` keys:
- `USE_HTTPS` (`false` by default)
- `MDNS_MODE` (`auto`, `host`, `container`, `off`)
- `SESSION_SECRET`
- `DEBUG` (`true` for local testing only: skips passkey/TOTP and shows a fixed warning footer)
- `TESLACAM_*_IMAGE` image references

## Keyboard shortcuts
Expand Down
2 changes: 2 additions & 0 deletions config.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ USE_HTTPS=false
# Linux mDNS: auto (host Avahi if available), host, container, or off
MDNS_MODE=auto
SESSION_SECRET=change-me-use-a-long-random-string
# Testing only: bypass passkey/TOTP on login and security setup (shows a fixed debug footer)
DEBUG=false

# Pre-built images (default). Override if you publish under your own GHCR org.
TESLACAM_WEB_IMAGE=ghcr.io/squishylemon/teslacam-web:latest
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,16 @@ services:
condition: service_started
restart: unless-stopped

# SMB library mounts need mount.cifs + capset (DAC_READ_SEARCH) inside the container.
web:
image: ${TESLACAM_WEB_IMAGE:-ghcr.io/squishylemon/teslacam-web:latest}
container_name: teslacam-viewer
cap_add:
- SYS_ADMIN
- DAC_READ_SEARCH
security_opt:
- seccomp:unconfined
- apparmor:unconfined
depends_on:
db:
condition: service_healthy
Expand All @@ -93,11 +100,13 @@ services:
- branding:/app/data/branding
- sftpconfig:/app/data/sftp
- webtls:/app/data/tls
- library_mounts:/app/data/libraries
restart: unless-stopped

volumes:
pgdata:
teslacam:
branding:
sftpconfig:
library_mounts:
webtls:
Binary file added public/vehicleImages/model_3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/vehicleImages/model_ct.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/vehicleImages/model_s.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/vehicleImages/model_x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/vehicleImages/model_y.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
221 changes: 219 additions & 2 deletions src/components/SettingsOverlay.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface Props {
librarySavedCount?: number;
librarySentryCount?: number;
libraryVehicleCount?: number;
builtinSftpEnabled?: boolean;
initialOpen?: boolean;
}

Expand All @@ -24,6 +25,7 @@ const {
librarySavedCount = 0,
librarySentryCount = 0,
libraryVehicleCount = 0,
builtinSftpEnabled = true,
initialOpen = false,
} = Astro.props;
const showAuthenticator = !user.hasPasskey;
Expand Down Expand Up @@ -377,13 +379,46 @@ const isAdmin = user.isAdmin;

{
isAdmin && (
<section class="settings-section" id="libraries-admin-section">
<h2>Library locations</h2>
<p class="settings-note">
Point the viewer at folders on this server, a NAS, or a DAS (SMB). Only administrators
can change locations.
</p>
<div class="settings-card libraries-card">
<label class="admin-check libraries-toggle">
<input
type="checkbox"
id="builtin-sftp-enabled"
checked={builtinSftpEnabled}
/>
<span>Enable built-in SFTP upload location</span>
</label>
<p class="field-hint">
When off, the SFTP upload section is hidden and clips come only from external
locations below.
</p>
<div class="libraries-toolbar">
<button type="button" class="step-btn" id="btn-new-location">
New location
</button>
</div>
<ul id="locations-list" class="locations-list" aria-live="polite" />
<p id="locations-status" class="admin-status" aria-live="polite" />
</div>
</section>
)
}

{
isAdmin && builtinSftpEnabled && (
<section class="settings-section" id="upload-section">
<h2>Upload clips (SFTP)</h2>
<p class="settings-note">
Copy TeslaCam footage from your car to this server over SFTP. You connect
straight into the upload area (no folder path needed). Use per-vehicle folders
like <code>MX_Name</code>, <code>MY_Name</code>, <code>MS_Name</code>, or{' '}
<code>M3_Name</code> (each with <code>SavedClips</code> and{' '}
like <code>MX_Name</code>, <code>MY_Name</code>, <code>MS_Name</code>,{' '}
<code>M3_Name</code>, or <code>CT_Name</code> (each with <code>SavedClips</code> and{' '}
<code>SentryClips</code> inside).
</p>
<div class="settings-card sftp-card">
Expand Down Expand Up @@ -497,6 +532,85 @@ const isAdmin = user.isAdmin;
}
</main>
</div>

<div
id="location-modal"
class="location-modal"
hidden
aria-hidden="true"
>
<div class="location-modal-backdrop" id="location-modal-backdrop" />
<div
class="location-modal-panel"
role="dialog"
aria-modal="true"
aria-labelledby="location-modal-title"
>
<h3 id="location-modal-title" class="location-modal-title">New location</h3>
<form id="location-form" class="location-form">
<input type="hidden" id="location-edit-id" value="" />
<label class="admin-field">
<span>Name</span>
<input type="text" id="location-name" required placeholder="Home NAS" />
</label>
<label class="admin-field">
<span>Folder path</span>
<input
type="text"
id="location-path"
required
placeholder="192.168.50.23/TeslaCam or /mnt/nas/TeslaCam"
/>
<p class="field-hint">
SMB: <code>192.168.50.189/TeslaCam</code> if <code>TeslaCam</code> is the share name, or
<code>192.168.50.189/Videos/TeslaCam</code> if <code>TeslaCam</code> is a folder inside share
<code>Videos</code>. Local paths must exist inside the container (bind-mount from the host).
</p>
</label>
<div class="location-creds-control">
<label class="admin-check">
<input type="checkbox" id="location-requires-credentials" />
<span>Requires credentials</span>
</label>
<p class="field-hint location-creds-hint">
Windows PCs usually block guest SMB. If mount fails with error 128, enable this and use a
Windows user that can open the share.
</p>
<div id="location-credentials" class="location-credentials">
<label class="admin-field">
<span>Username</span>
<input
type="text"
id="location-username"
autocomplete="off"
disabled
tabindex="-1"
/>
</label>
<label class="admin-field">
<span>Password</span>
<input
type="password"
id="location-password"
autocomplete="new-password"
disabled
tabindex="-1"
/>
</label>
</div>
</div>
<label class="admin-check">
<input type="checkbox" id="location-enabled" checked />
<span>Enabled</span>
</label>
<div class="location-modal-actions">
<button type="submit" class="step-btn" id="location-save">Save location</button>
<button type="button" class="btn-ghost" id="location-cancel">Cancel</button>
</div>
<p id="location-form-status" class="admin-status" aria-live="polite" />
</form>
</div>
</div>
</div>

<style is:global>
Expand Down Expand Up @@ -672,6 +786,109 @@ const isAdmin = user.isAdmin;
color: var(--accent);
border-color: rgba(232, 33, 39, 0.35);
}
.libraries-toggle {
margin-bottom: 0.5rem;
}
.libraries-toolbar {
margin: 0.75rem 0;
}
.locations-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.location-item {
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.65rem 0.75rem;
background: var(--bg-elev);
}
.location-item-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: flex-start;
}
.location-item-name {
font-weight: 700;
margin: 0 0 0.2rem;
}
.location-item-path {
font-size: 0.78rem;
color: var(--text-dim);
word-break: break-all;
margin: 0;
}
.location-item-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
}
.location-modal {
position: fixed;
inset: 0;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.location-modal[hidden] {
display: none !important;
}
.location-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.location-modal-panel {
position: relative;
z-index: 2;
width: min(100%, 420px);
max-height: 90vh;
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.1rem 1rem 1rem;
box-shadow: var(--shadow);
}
.location-modal-title {
margin: 0 0 0.85rem;
font-size: 1.05rem;
}
.location-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.location-creds-control {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.location-creds-hint {
margin: 0 0 0.15rem;
}
.location-credentials {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.5rem 0;
}
.location-creds-control:has(#location-requires-credentials:not(:checked)) .location-credentials {
display: none !important;
}
.location-modal-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.35rem;
}
.sftp-details {
margin: 0 0 0.75rem;
display: flex;
Expand Down
Loading
Loading