diff --git a/README.md b/README.md
index 26cba793..a1fbbc60 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ If Fireshare is useful to you, [GitHub Sponsors](https://github.com/sponsors/Sha
- [Shareable user profiles for uploads](./docs/Users.md#profiles)
- [Two-factor authentication (TOTP authenticator apps)](./docs/Security.md#two-factor-authentication-mfa)
- [Login IP whitelisting](./docs/Security.md#login-ip-whitelist)
+- [Upload tokens for scripts and other tools](./docs/UploadTokens.md)
## Supported Video Formats
diff --git a/app/client/package-lock.json b/app/client/package-lock.json
index b83da6c3..7b825519 100644
--- a/app/client/package-lock.json
+++ b/app/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "fireshare",
- "version": "1.8.2",
+ "version": "1.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fireshare",
- "version": "1.8.2",
+ "version": "1.8.3",
"dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
diff --git a/app/client/package.json b/app/client/package.json
index 14fb5811..5cd0f99c 100644
--- a/app/client/package.json
+++ b/app/client/package.json
@@ -1,6 +1,6 @@
{
"name": "fireshare",
- "version": "1.8.2",
+ "version": "1.8.3",
"private": true,
"dependencies": {
"@emotion/react": "^11.9.0",
diff --git a/app/client/src/App.js b/app/client/src/App.js
index 9f4196eb..ac6c3fb2 100644
--- a/app/client/src/App.js
+++ b/app/client/src/App.js
@@ -50,22 +50,25 @@ export default function App() {
+ {/* "/" is not a page of its own: it forwards to whichever page the
+ administrator put at the top of the sidebar. The Videos page it
+ used to hold now lives at "/videos" so that the sidebar can link
+ to it without passing back through that decision. */}
+ } />
-
-
-
-
-
-
+
+
+
+
+
}
/>
p.enabled)
- return first ? KNOWN.get(first.key).href : '/'
+ return first ? KNOWN.get(first.key).href : '/videos'
}
diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js
index dff7d09e..cbed9acd 100644
--- a/app/client/src/components/cards/UploadCard.js
+++ b/app/client/src/components/cards/UploadCard.js
@@ -17,6 +17,7 @@ import {
InputAdornment,
Checkbox,
FormControlLabel,
+ Link,
} from '@mui/material'
import CloudUploadIcon from '@mui/icons-material/CloudUpload'
import styled from '@emotion/styled'
@@ -24,6 +25,55 @@ import { keyframes } from '@emotion/react'
import { VideoService, GameService, TagService } from '../../services'
import { getSetting } from '../../common/utils'
+const FINISHED_STATUSES = new Set(['done', 'error', 'duplicate'])
+const isFinished = (item) => FINISHED_STATUSES.has(item.status)
+
+function summarizeBatch(succeeded, duplicates, failed, total) {
+ if (duplicates.length === 0 && failed === 0) {
+ return succeeded === 1
+ ? 'Your upload will be available in a few seconds.'
+ : `${succeeded} uploads will be available in a few seconds.`
+ }
+ if (succeeded === 0 && failed === 0) {
+ return duplicates.length === 1
+ ? `${duplicates[0].file.name} is already in the library.`
+ : `All ${duplicates.length} of those videos are already in the library.`
+ }
+ const parts = []
+ if (duplicates.length > 0) parts.push(`${duplicates.length} already in the library`)
+ if (failed > 0) parts.push(`${failed} failed`)
+ return `${succeeded} of ${total} uploads succeeded — ${parts.join(', ')}.`
+}
+
+// Links to the existing copies of duplicate uploads. They open in a new tab so
+// the page the uploader is sitting on is left alone.
+function renderDuplicateLinks(items) {
+ const link = (item, label) => (
+
+ {label}
+
+ )
+ if (items.length === 1) return link(items[0], 'Open the existing video')
+ return (
+ <>
+ Open the existing videos:{' '}
+ {items.map((item, idx) => (
+
+ {idx > 0 && ', '}
+ {link(item, item.duplicate.title || item.file.name)}
+
+ ))}
+ >
+ )
+}
+
function checkUploadLimit(file, handleAlert) {
const limitMb = getSetting('upload_limit_mb') || 0
if (limitMb > 0 && file.size > limitMb * 1024 * 1024) {
@@ -473,6 +523,13 @@ const UploadCard = React.forwardRef(function UploadCard(
updateQueueItem(item.id, { status: 'done', progress: 1 })
if (onUploadComplete) onUploadComplete()
} catch (err) {
+ // The server hashes every upload and answers 409 when that content is
+ // already in the library. It has removed its copy; nothing more to do.
+ const duplicate = err?.response?.status === 409 ? err.response.data : null
+ if (duplicate?.video_id) {
+ updateQueueItem(item.id, { status: 'duplicate', progress: 1, duplicate })
+ return
+ }
updateQueueItem(item.id, { status: 'error' })
handleAlert({
type: 'error',
@@ -500,19 +557,21 @@ const UploadCard = React.forwardRef(function UploadCard(
}
// Once every upload has finished, show a single summary alert and reset the card
- if (uploadQueue.every((i) => i.status === 'done' || i.status === 'error')) {
+ if (uploadQueue.every(isFinished)) {
const succeeded = uploadQueue.filter((i) => i.status === 'done').length
- const failed = uploadQueue.length - succeeded
- if (succeeded > 0) {
+ const duplicates = uploadQueue.filter((i) => i.status === 'duplicate')
+ const failed = uploadQueue.length - succeeded - duplicates.length
+ if (succeeded > 0 || duplicates.length > 0) {
handleAlert({
- type: failed > 0 ? 'warning' : 'success',
- message:
- failed > 0
- ? `${succeeded} of ${uploadQueue.length} uploads succeeded — ${failed} failed.`
- : succeeded === 1
- ? 'Your upload will be available in a few seconds.'
- : `${succeeded} uploads will be available in a few seconds.`,
- autohideDuration: 3500,
+ type: failed > 0 ? 'warning' : succeeded > 0 ? 'success' : 'info',
+ message: (
+ <>
+ {summarizeBatch(succeeded, duplicates, failed, uploadQueue.length)}
+ {duplicates.length > 0 && <> {renderDuplicateLinks(duplicates)}>}
+ >
+ ),
+ // A message with a link in it needs to stay up long enough to click.
+ autoHideDuration: duplicates.length > 0 ? 10000 : 3500,
open: true,
})
}
@@ -539,7 +598,7 @@ const UploadCard = React.forwardRef(function UploadCard(
0,
)
const aggregateProgress = totalQueueBytes > 0 ? Math.min(loadedQueueBytes / totalQueueBytes, 1) : 0
- const finishedCount = uploadQueue.filter((i) => i.status === 'done' || i.status === 'error').length
+ const finishedCount = uploadQueue.filter(isFinished).length
const allSent = isUploading && uploadQueue.every((i) => i.status !== 'queued' && i.status !== 'uploading')
const singleUpload = uploadQueue.length === 1 ? uploadQueue[0] : null
@@ -1179,9 +1238,11 @@ const UploadCard = React.forwardRef(function UploadCard(
color:
item.status === 'error'
? '#FF6B6B'
- : item.status === 'done'
- ? '#6BFF95'
- : '#FFFFFFCC',
+ : item.status === 'duplicate'
+ ? '#FFD166'
+ : item.status === 'done'
+ ? '#6BFF95'
+ : '#FFFFFFCC',
}}
>
{item.status === 'queued'
@@ -1192,7 +1253,9 @@ const UploadCard = React.forwardRef(function UploadCard(
? 'Done'
: item.status === 'error'
? 'Failed'
- : `${(100 * item.progress).toFixed(0)}%`}
+ : item.status === 'duplicate'
+ ? 'Already exists'
+ : `${(100 * item.progress).toFixed(0)}%`}
diff --git a/app/client/src/components/nav/MainNavbar.js b/app/client/src/components/nav/MainNavbar.js
index 6c4409c9..dadbe627 100644
--- a/app/client/src/components/nav/MainNavbar.js
+++ b/app/client/src/components/nav/MainNavbar.js
@@ -70,7 +70,7 @@ const PAGES_WITHOUT_TOP_BAR = ['/files', '/settings', '/image', '/profile']
// group that always follows them.
const allPages = [
{ key: 'home', title: 'Home', icon: , href: '/home', private: false },
- { key: 'videos', title: 'Videos', icon: , href: '/', private: false },
+ { key: 'videos', title: 'Videos', icon: , href: '/videos', private: false },
{ key: 'images', title: 'Images', icon: , href: '/images', private: false },
{ key: 'games', title: 'Games', icon: , href: '/games', private: false },
{ key: 'tags', title: 'Tags', icon: , href: '/tags', private: false },
@@ -841,7 +841,12 @@ function MainNavbar({
>
{showDemoBanner && }
{toolbar && page !== '/watch' && showTopBar && }
- setAlert({ ...alert, open })}>
+ setAlert({ ...alert, open })}
+ >
{alert.message}
{React.cloneElement(children, {
diff --git a/app/client/src/components/settings/UploadTokens.js b/app/client/src/components/settings/UploadTokens.js
new file mode 100644
index 00000000..81be4dd8
--- /dev/null
+++ b/app/client/src/components/settings/UploadTokens.js
@@ -0,0 +1,412 @@
+import React from 'react'
+import {
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ IconButton,
+ Stack,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import TerminalIcon from '@mui/icons-material/Terminal'
+import CloudSyncIcon from '@mui/icons-material/CloudSync'
+import OpenInNewIcon from '@mui/icons-material/OpenInNew'
+import ContentCopyIcon from '@mui/icons-material/ContentCopy'
+import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
+import AutorenewIcon from '@mui/icons-material/Autorenew'
+import AddIcon from '@mui/icons-material/Add'
+import { UserService } from '../../services'
+import SnackbarAlert from '../alert/SnackbarAlert'
+import { dialogPaperSx, dialogTitleSx, inputSx, helperTextSx, rowBoxSx } from '../../common/modalStyles'
+
+const NAME_MAX = 64
+const DOCS_URL = 'https://github.com/fireshare-app/fireshare/blob/main/docs/UploadTokens.md'
+const FIRESYNC_URL = 'https://github.com/fireshare-app/firesync/releases'
+
+// Matches the external links in the Settings panes.
+const docsLinkStyle = { color: '#2684FF', textDecoration: 'none' }
+
+// The API serialises these columns as naive UTC, so the zone has to be supplied
+// here — without it the browser would read the timestamp as local time.
+const formatDate = (value) => {
+ if (!value) return null
+ const parsed = new Date(`${value}Z`)
+ if (Number.isNaN(parsed.getTime())) return null
+ return parsed.toLocaleString()
+}
+
+const errorMessage = (err, fallback) => err.response?.data?.error || fallback
+
+const UploadTokens = () => {
+ const [tokens, setTokens] = React.useState(null)
+ const [maxTokens, setMaxTokens] = React.useState(20)
+ const [createOpen, setCreateOpen] = React.useState(false)
+ const [name, setName] = React.useState('')
+ const [busy, setBusy] = React.useState(false)
+ const [alert, setAlert] = React.useState({ open: false })
+ // The one moment the raw token exists on the client. Held only in component
+ // state so it is gone the moment the dialog closes or the page is left.
+ const [revealed, setRevealed] = React.useState(null)
+ const [confirmAction, setConfirmAction] = React.useState(null)
+
+ const load = React.useCallback(async () => {
+ try {
+ const { data } = await UserService.listUploadTokens()
+ setTokens(data.tokens || [])
+ if (data.max_tokens) setMaxTokens(data.max_tokens)
+ } catch (err) {
+ setTokens([])
+ setAlert({ open: true, type: 'error', message: 'Failed to load upload tokens.' })
+ }
+ }, [])
+
+ React.useEffect(() => {
+ load()
+ }, [load])
+
+ const copy = async (value) => {
+ try {
+ await navigator.clipboard.writeText(value)
+ setAlert({ open: true, type: 'success', message: 'Token copied to the clipboard.' })
+ } catch {
+ setAlert({ open: true, type: 'error', message: 'Could not copy — select the token and copy it manually.' })
+ }
+ }
+
+ const handleCreate = async () => {
+ setBusy(true)
+ try {
+ const { data } = await UserService.createUploadToken(name.trim() || 'Upload token')
+ setCreateOpen(false)
+ setName('')
+ setRevealed({ ...data.token, isNew: true })
+ await load()
+ } catch (err) {
+ setAlert({ open: true, type: 'error', message: errorMessage(err, 'Could not create the token.') })
+ }
+ setBusy(false)
+ }
+
+ const handleRegenerate = async (token) => {
+ setBusy(true)
+ try {
+ const { data } = await UserService.regenerateUploadToken(token.id)
+ setConfirmAction(null)
+ setRevealed({ ...data.token, isNew: false })
+ await load()
+ } catch (err) {
+ setAlert({ open: true, type: 'error', message: errorMessage(err, 'Could not regenerate the token.') })
+ }
+ setBusy(false)
+ }
+
+ const handleDelete = async (token) => {
+ setBusy(true)
+ try {
+ await UserService.deleteUploadToken(token.id)
+ setConfirmAction(null)
+ setAlert({ open: true, type: 'success', message: `"${token.name}" was deleted.` })
+ await load()
+ } catch (err) {
+ setAlert({ open: true, type: 'error', message: errorMessage(err, 'Could not delete the token.') })
+ }
+ setBusy(false)
+ }
+
+ const atLimit = tokens !== null && tokens.length >= maxTokens
+
+ return (
+
+ setAlert({ ...alert, open })}>
+ {alert.message}
+
+
+
+
+ Upload Tokens
+
+
+
+ Let a script or another tool upload videos and images on your behalf, without your password.{' '}
+
+ Read the documentation
+
+ .
+
+
+ {/* A token is only half of what someone wanting automatic uploads needs, and
+ writing the other half yourself is the assumed default until told
+ otherwise. Said here because this is the page they are on when the
+ question occurs to them. */}
+
+
+
+
+ Firesync
+
+
+ Rather not write the script yourself? Firesync is a companion app for Windows and Linux that watches
+ folders on your machine and uploads new clips and screenshots here on its own, with per-folder rules for
+ where they land. It signs in with a token from this page, never your password.
+
+
+ Download Firesync
+
+
+
+
+
+ {tokens === null ? (
+
+ ) : (
+
+ {tokens.length === 0 && (
+
+ You have no upload tokens yet.
+
+ )}
+
+ {tokens.map((token) => (
+
+
+
+ {token.name}
+
+
+ {token.prefix}
+ {'…'}
+
+
+ {`Created ${formatDate(token.created_at) || 'unknown'}`}
+ {' · '}
+ {token.last_used_at ? `last used ${formatDate(token.last_used_at)}` : 'never used'}
+
+
+
+
+ setConfirmAction({ type: 'regenerate', token })}
+ sx={{ color: '#FFFFFFB3' }}
+ >
+
+
+
+
+ setConfirmAction({ type: 'delete', token })}
+ sx={{ color: '#FF6B6B' }}
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+ }
+ disabled={atLimit}
+ onClick={() => {
+ setName('')
+ setCreateOpen(true)
+ }}
+ >
+ Create token
+
+
+
+
+
+ )}
+
+ {/* Create */}
+
+
+ {/* Reveal-once secret */}
+
+
+ {/* Regenerate / delete confirmation */}
+
+
+ )
+}
+
+export default UploadTokens
diff --git a/app/client/src/components/utils/LandingRoute.js b/app/client/src/components/utils/LandingRoute.js
index ca280865..be06da26 100644
--- a/app/client/src/components/utils/LandingRoute.js
+++ b/app/client/src/components/utils/LandingRoute.js
@@ -4,15 +4,15 @@ import { ConfigService } from '../../services'
import { getSetting, setSetting } from '../../common/utils'
import { landingHref } from '../../common/sidebarPages'
-// "/" opens whichever content page the administrator put at the top of the
-// sidebar (Settings → Sidebar). The Videos page lives at "/" itself, so when it
-// is on top the children render here directly and every existing link keeps
-// working; any other page is a redirect that carries the query string along.
+// "/" is a redirect, not a page: it opens whichever content page the
+// administrator put at the top of the sidebar (Settings → Sidebar), carrying
+// the query string along. Every content page, Videos included, has a path of
+// its own, so a link to one is never routed through this decision.
//
// The decision is made from the cached ui_config so a returning visitor is not
// held up by a request. Only a browser that has never loaded the app waits for
// the config, since guessing there would send it to the wrong page.
-export default function LandingRoute({ children }) {
+export default function LandingRoute() {
const location = useLocation()
const [target, setTarget] = React.useState(() => landingHref(getSetting('ui_config')))
@@ -26,7 +26,7 @@ export default function LandingRoute({ children }) {
setTarget(landingHref(res.data))
})
.catch(() => {
- if (!cancelled) setTarget('/')
+ if (!cancelled) setTarget('/videos')
})
return () => {
cancelled = true
@@ -34,6 +34,5 @@ export default function LandingRoute({ children }) {
}, [target])
if (target === undefined) return null
- if (target === '/') return children
return
}
diff --git a/app/client/src/services/UserService.js b/app/client/src/services/UserService.js
index 62a18483..317cb69a 100644
--- a/app/client/src/services/UserService.js
+++ b/app/client/src/services/UserService.js
@@ -52,6 +52,23 @@ class UserService {
})
}
+ // --- Upload tokens (machine credentials for the authenticated user) ---
+ listUploadTokens() {
+ return Api().get('/api/account/upload-tokens')
+ }
+ createUploadToken(name) {
+ return Api().post('/api/account/upload-tokens', { name })
+ }
+ renameUploadToken(id, name) {
+ return Api().put(`/api/account/upload-tokens/${id}`, { name })
+ }
+ regenerateUploadToken(id) {
+ return Api().post(`/api/account/upload-tokens/${id}/regenerate`)
+ }
+ deleteUploadToken(id) {
+ return Api().delete(`/api/account/upload-tokens/${id}`)
+ }
+
// --- Invite redemption (unauthenticated) ---
checkSetupToken(token) {
return Api().get('/api/account/setup-password', { params: { token } })
diff --git a/app/client/src/views/Profile.js b/app/client/src/views/Profile.js
index a02046b1..9bb89517 100644
--- a/app/client/src/views/Profile.js
+++ b/app/client/src/views/Profile.js
@@ -340,7 +340,7 @@ const Profile = ({ authenticated }) => {
This user does not exist, or their profile is not shared.
-