diff --git a/Dockerfile b/Dockerfile index 8eaddce0..9735d89f 100755 --- a/Dockerfile +++ b/Dockerfile @@ -162,7 +162,7 @@ RUN ln -sf /dev/stdout /var/log/nginx/access.log \ && ln -sf /dev/stderr /var/log/nginx/error.log RUN mkdir /data && mkdir /processed COPY entrypoint.sh / -COPY app/nginx/prod.conf /etc/nginx/nginx.conf +COPY app/nginx/prod.conf /etc/nginx/nginx.conf.template COPY app/nginx/error.html /etc/nginx/error.html COPY app/nginx/api_unavailable.html /etc/nginx/api_unavailable.html COPY app/server/ /app/server diff --git a/Dockerfile.lite b/Dockerfile.lite index f1c7153d..12dacd80 100644 --- a/Dockerfile.lite +++ b/Dockerfile.lite @@ -30,7 +30,7 @@ RUN adduser --disabled-password --gecos '' nginx RUN mkdir /data && mkdir /processed COPY entrypoint.sh / COPY entrypoint-lite.sh / -COPY app/nginx/prod.conf /etc/nginx/nginx.conf +COPY app/nginx/prod.conf /etc/nginx/nginx.conf.template COPY app/nginx/error.html /etc/nginx/error.html COPY app/nginx/api_unavailable.html /etc/nginx/api_unavailable.html COPY app/server/ /app/server diff --git a/README.md b/README.md index 883e534b..6b24b744 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ services: - ADMIN_USERNAME=your-admin-username - ADMIN_PASSWORD=your-admin-password - SECRET_KEY=replace_with_random_string_can_be_anything - # The domain your instance is hosted at. e.x: v.fireshare.net + # The domain your instance is hosted at. e.x: demo.fireshare.net # this is required for opengraph to work correctly for shared links. - DOMAIN= # PUID/PGID: the user/group ID the container runs as. Files written to your @@ -138,6 +138,10 @@ services: # Run `id` on your host to find your UID and GID. - PUID=1000 - PGID=1000 + # The port the web server (nginx) listens on inside the container (default: 80). + # Leave this alone and change the host-side "ports" mapping above unless you run + # with network_mode: host, where "ports" is ignored and this becomes the host port. + # - FIRESHARE_PORT=80 ``` Update the volume paths and credentials, then run: diff --git a/app/client/package-lock.json b/app/client/package-lock.json index 9a2d983f..ebd6665a 100644 --- a/app/client/package-lock.json +++ b/app/client/package-lock.json @@ -1,6 +1,6 @@ { "name": "fireshare", - "version": "1.7.0", + "version": "1.7.2", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/app/client/package.json b/app/client/package.json index 3788bc78..f3927dc6 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -1,6 +1,6 @@ { "name": "fireshare", - "version": "1.7.1", + "version": "1.7.2", "private": true, "dependencies": { "@emotion/react": "^11.9.0", diff --git a/app/client/src/components/cards/MasonryImageCard.js b/app/client/src/components/cards/MasonryImageCard.js index d91deb5d..41a8f767 100644 --- a/app/client/src/components/cards/MasonryImageCard.js +++ b/app/client/src/components/cards/MasonryImageCard.js @@ -322,6 +322,15 @@ const MasonryImageCard = ({ {image.game.name} )} + {image.created_at && ( + + {new Date(image.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + )} {localTags.length > 0 && ( diff --git a/app/client/src/components/modal/DateField.js b/app/client/src/components/modal/DateField.js new file mode 100644 index 00000000..024df01d --- /dev/null +++ b/app/client/src/components/modal/DateField.js @@ -0,0 +1,84 @@ +import * as React from 'react' +import { Box, Typography, Button, IconButton, Popover } from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth' +import { DayPicker } from 'react-day-picker' +import './datepicker-dark.css' +import { rowBoxSx, timeInputStyle } from '../../common/modalStyles' + +const DateField = ({ selectedDate, selectedTime, onDateChange, onTimeChange }) => { + const [anchor, setAnchor] = React.useState(null) + + const display = selectedDate + ? selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + + (selectedTime ? ` at ${selectedTime}` : '') + : null + + return ( + <> + setAnchor(e.currentTarget)} + sx={{ ...rowBoxSx, cursor: 'pointer', py: 1.1, '&:hover': { borderColor: '#FFFFFF55' } }} + > + + + {display || 'Pick a date…'} + + {selectedDate && ( + { + e.stopPropagation() + onDateChange(null) + onTimeChange('') + }} + sx={{ color: '#FFFFFF66', '&:hover': { color: 'white' }, p: 0.25 }} + > + + + )} + + + setAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }} + slotProps={{ paper: { sx: { bgcolor: 'transparent', boxShadow: 'none', mt: 0.5 } } }} + > +
+ onDateChange(d || null)} + defaultMonth={selectedDate || new Date()} + captionLayout="dropdown" + startMonth={new Date(1970, 0)} + endMonth={new Date(new Date().getFullYear() + 1, 11)} + /> + + Time + onTimeChange(e.target.value)} + style={timeInputStyle} + /> + + +
+
+ + ) +} + +export default DateField diff --git a/app/client/src/components/modal/EditImageModal.js b/app/client/src/components/modal/EditImageModal.js index 927c9638..1446ef96 100644 --- a/app/client/src/components/modal/EditImageModal.js +++ b/app/client/src/components/modal/EditImageModal.js @@ -25,6 +25,7 @@ import { ImageService } from '../../services' import { getPublicImageUrl, getImageUrl } from '../../common/utils' import { labelSx, inputSx, dialogPaperSx } from '../../common/modalStyles' import GameSearch from '../game/GameSearch' +import DateField from './DateField' const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onNext, onPrev }) => { const theme = useTheme() @@ -35,9 +36,12 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN const [imgLoaded, setImgLoaded] = React.useState(false) const [showSwipeHint, setShowSwipeHint] = React.useState(false) const [panningDisabled, setPanningDisabled] = React.useState(true) + const [selectedDate, setSelectedDate] = React.useState(null) + const [selectedTime, setSelectedTime] = React.useState('') const wasOpenRef = React.useRef(false) const saveTimerRef = React.useRef(null) const latestTitleRef = React.useRef('') + const latestCreatedAtRef = React.useRef(undefined) const transformRef = React.useRef(null) const prevZoomedRef = React.useRef(false) @@ -54,7 +58,10 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN setImgLoaded(false) setShowSwipeHint(false) setPanningDisabled(true) + setSelectedDate(null) + setSelectedTime('') latestTitleRef.current = '' + latestCreatedAtRef.current = undefined return } const t = @@ -67,7 +74,17 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN : 'Untitled') setTitle(t) latestTitleRef.current = t + latestCreatedAtRef.current = undefined setPrivateView(image.info?.private || false) + if (image.created_at) { + const d = new Date(image.created_at) + const pad = (n) => n.toString().padStart(2, '0') + setSelectedDate(d) + setSelectedTime(`${pad(d.getHours())}:${pad(d.getMinutes())}`) + } else { + setSelectedDate(null) + setSelectedTime('') + } ImageService.addView(image.image_id).catch(() => {}) ImageService.getGame(image.image_id) .then((res) => setSelectedGame(res.data?.game || null)) @@ -174,7 +191,12 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN saveTimerRef.current = null ImageService.updateDetails(imageId, { title: latestTitleRef.current }).catch(() => {}) } - onClose({ title: latestTitleRef.current, private: privateView, game: selectedGame }) + onClose({ + title: latestTitleRef.current, + private: privateView, + game: selectedGame, + ...(latestCreatedAtRef.current !== undefined && { created_at: latestCreatedAtRef.current }), + }) } const handleGameLinked = async (game, warning) => { @@ -215,6 +237,42 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN } } + // Build a naive local ISO string (no Z/offset) so the server stores the time + // exactly as entered, without any timezone conversion. + const getCreatedAtISO = (dateVal, timeVal) => { + if (!dateVal) return null + const d = new Date(dateVal) + if (timeVal) { + const [h, m] = timeVal.split(':') + d.setHours(+h, +m, 0, 0) + } + const pad = (n) => n.toString().padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad( + d.getMinutes(), + )}:00` + } + + const persistCreatedAt = async (dateVal, timeVal) => { + const iso = getCreatedAtISO(dateVal, timeVal) + latestCreatedAtRef.current = iso + try { + await ImageService.updateDetails(imageId, { created_at: iso }) + alertHandler?.({ open: true, type: 'success', message: 'Created date updated.' }) + } catch (err) { + alertHandler?.({ open: true, type: 'error', message: 'Failed to save created date.' }) + } + } + + const handleDateChange = (d) => { + setSelectedDate(d) + persistCreatedAt(d, selectedTime) + } + + const handleTimeChange = (t) => { + setSelectedTime(t) + persistCreatedAt(selectedDate, t) + } + const handleDownload = () => { const a = document.createElement('a') a.href = `/api/image/original?id=${imageId}` @@ -428,6 +486,19 @@ const EditImageModal = ({ open, onClose, image, alertHandler, authenticated, onN )} + {/* Created Date */} + {authenticated && ( + + Created Date + + + )} + {/* Privacy */} {authenticated && ( diff --git a/app/client/src/components/modal/UpdateDetailsModal.js b/app/client/src/components/modal/UpdateDetailsModal.js index 7d977283..36b412b8 100644 --- a/app/client/src/components/modal/UpdateDetailsModal.js +++ b/app/client/src/components/modal/UpdateDetailsModal.js @@ -9,7 +9,6 @@ import { InputAdornment, IconButton, Divider, - Popover, Chip, Autocomplete, CircularProgress, @@ -18,14 +17,12 @@ import TagChip from '../ui/TagChip' import CloseIcon from '@mui/icons-material/Close' import CheckIcon from '@mui/icons-material/Check' import RefreshIcon from '@mui/icons-material/Refresh' -import CalendarMonthIcon from '@mui/icons-material/CalendarMonth' import LockIcon from '@mui/icons-material/Lock' import ContentCopyIcon from '@mui/icons-material/ContentCopy' -import { DayPicker } from 'react-day-picker' import { VideoService, GameService, TagService } from '../../services' import GameSearch from '../game/GameSearch' -import './datepicker-dark.css' -import { labelSx, inputSx, rowBoxSx, dialogPaperSx, timeInputStyle } from '../../common/modalStyles' +import DateField from './DateField' +import { labelSx, inputSx, rowBoxSx, dialogPaperSx } from '../../common/modalStyles' const modalSx = { position: 'absolute', @@ -49,81 +46,6 @@ const LabeledField = ({ label, children }) => ( ) -const DateField = ({ selectedDate, selectedTime, onDateChange, onTimeChange }) => { - const [anchor, setAnchor] = React.useState(null) - - const display = selectedDate - ? selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + - (selectedTime ? ` at ${selectedTime}` : '') - : null - - return ( - <> - setAnchor(e.currentTarget)} - sx={{ ...rowBoxSx, cursor: 'pointer', py: 1.1, '&:hover': { borderColor: '#FFFFFF55' } }} - > - - - {display || 'Pick a date…'} - - {selectedDate && ( - { - e.stopPropagation() - onDateChange(null) - onTimeChange('') - }} - sx={{ color: '#FFFFFF66', '&:hover': { color: 'white' }, p: 0.25 }} - > - - - )} - - - setAnchor(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} - transformOrigin={{ vertical: 'top', horizontal: 'left' }} - slotProps={{ paper: { sx: { bgcolor: 'transparent', boxShadow: 'none', mt: 0.5 } } }} - > -
- onDateChange(d || null)} - defaultMonth={selectedDate || new Date()} - captionLayout="dropdown" - startMonth={new Date(1970, 0)} - endMonth={new Date(new Date().getFullYear() + 1, 11)} - /> - - Time - onTimeChange(e.target.value)} - style={timeInputStyle} - /> - - -
-
- - ) -} - const LinkedGameField = ({ game, onLink, onUnlink, alertHandler }) => { if (game) { return ( diff --git a/app/client/src/views/FolderView.js b/app/client/src/views/FolderView.js index 4f1e7fc4..fcc8ad16 100644 --- a/app/client/src/views/FolderView.js +++ b/app/client/src/views/FolderView.js @@ -87,6 +87,7 @@ const FolderView = ({ authenticated, cardSize, searchText }) => { ...(update.private !== undefined && { private: update.private }), }, ...(update.game !== undefined && { game: update.game }), + ...(update.created_at !== undefined && { created_at: update.created_at }), } } setMedia((prev) => prev.map(updateImage)) diff --git a/app/client/src/views/GameVideos.js b/app/client/src/views/GameVideos.js index c51ab436..cf6ca987 100644 --- a/app/client/src/views/GameVideos.js +++ b/app/client/src/views/GameVideos.js @@ -331,6 +331,7 @@ const GameVideos = ({ cardSize, authenticated, searchText }) => { ...(update.private !== undefined && { private: update.private }), }, ...(update.game !== undefined && { game: update.game }), + ...(update.created_at !== undefined && { created_at: update.created_at }), } }), ) @@ -345,6 +346,7 @@ const GameVideos = ({ cardSize, authenticated, searchText }) => { ...(update.private !== undefined && { private: update.private }), }, ...(update.game !== undefined && { game: update.game }), + ...(update.created_at !== undefined && { created_at: update.created_at }), } }), ) diff --git a/app/client/src/views/ImageFeed.js b/app/client/src/views/ImageFeed.js index 48196f13..0f806575 100644 --- a/app/client/src/views/ImageFeed.js +++ b/app/client/src/views/ImageFeed.js @@ -285,6 +285,7 @@ const ImageFeed = ({ authenticated, searchText, cardSize, selectedImageFolder, o ...(update.private !== undefined && { private: update.private }), }, ...(update.game !== undefined && { game: update.game }), + ...(update.created_at !== undefined && { created_at: update.created_at }), } } setImages((prev) => prev.map(updateImage)) diff --git a/app/nginx/prod.conf b/app/nginx/prod.conf index 54cc7b28..7f60c6fe 100644 --- a/app/nginx/prod.conf +++ b/app/nginx/prod.conf @@ -51,7 +51,7 @@ http { proxy_cache_lock_timeout 10s; server { - listen 80 default_server reuseport; + listen __FIRESHARE_PORT__ default_server reuseport; server_name _; gzip on; diff --git a/app/server/fireshare/api/image.py b/app/server/fireshare/api/image.py index 430070a9..103b2b75 100644 --- a/app/server/fireshare/api/image.py +++ b/app/server/fireshare/api/image.py @@ -303,6 +303,18 @@ def update_image_details(image_id): img.info.description = data['description'] if 'private' in data: img.info.private = bool(data['private']) + if 'created_at' in data: + created_at = data['created_at'] + if not created_at: + img.created_at = None + else: + try: + # Strip any timezone suffix and store as naive local datetime, + # matching how video recorded_at is persisted. + dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + img.created_at = dt.replace(tzinfo=None) + except (ValueError, AttributeError): + pass db.session.commit() return Response(status=200) diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py index bd549aed..bf712aae 100755 --- a/app/server/fireshare/cli.py +++ b/app/server/fireshare/cli.py @@ -417,7 +417,7 @@ def scan_videos(root): if generic_webhook_url: logger.info(f"Posting to Generic webhook for {nv.video_id}") payload_str = json.dumps(generic_webhook_payload) - processed_payload_str = payload_str.replace("[[video_url]]", video_url) + processed_payload_str = payload_str.replace("[[video_url]]", video_url or "") final_payload = json.loads(processed_payload_str) send_generic_webhook( webhook_url=generic_webhook_url, @@ -652,7 +652,7 @@ def scan_video(ctx, path, tag_ids, game_id, title): video_url = get_public_watch_url(video_id, config, domain) payload_str = json.dumps(generic_webhook_payload) #Replaces plain text json [[video_url]] with the real video_url python var - processed_payload_str = payload_str.replace("[[video_url]]", video_url) + processed_payload_str = payload_str.replace("[[video_url]]", video_url or "") final_payload = json.loads(processed_payload_str) send_generic_webhook( webhook_url=generic_webhook_url, diff --git a/docker-compose.yml b/docker-compose.yml index 1247672b..cd227c0b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,10 +22,15 @@ services: # - STEAMGRIDDB_API_KEY= # The location in the video thumbnails are generated. A value between 0-100 where 50 would be the frame in the middle of the video file and 0 would be the first frame of the video. - THUMBNAIL_VIDEO_LOCATION=0 - # The domain your instance is hosted at. (do not add http or https) e.x: v.fireshare.net, this is required for opengraph to work correctly for shared links. DO NOT SURROUND IN QUOTES. + # The domain your instance is hosted at. (do not add http or https) e.x: demo.fireshare.net, this is required for opengraph to work correctly for shared links. DO NOT SURROUND IN QUOTES. - DOMAIN= - PUID=1000 - PGID=1000 + # The port Fireshare's web server (nginx) listens on inside the container (default: 80). + # Most users should leave this alone and change the host-side "ports" mapping above instead. + # This is primarily useful when running with network_mode: host, where the "ports" mapping + # is ignored and this value becomes the port exposed directly on the host. + # - FIRESHARE_PORT=80 # Enable transcoding to create 720p and 1080p variants (default: false) - ENABLE_TRANSCODING=false # Enable GPU acceleration for transcoding using NVENC (requires nvidia-docker runtime, default: false) diff --git a/docs/EnvironmentVariables.md b/docs/EnvironmentVariables.md index 1e4e426d..a5024886 100644 --- a/docs/EnvironmentVariables.md +++ b/docs/EnvironmentVariables.md @@ -37,6 +37,7 @@ | `PUID` | User ID the container process runs as. Useful for matching host file permissions. | `1000` | | `PGID` | Group ID the container process runs as. Useful for matching host file permissions. | `1000` | | **Web Server** | | | +| `FIRESHARE_PORT` | The port the web server (nginx) listens on inside the container. Most users should leave this at `80` and change the host-side `ports` mapping instead; it is primarily useful with `network_mode: host`, where the `ports` mapping is ignored and this value is the port exposed directly on the host. | `80` | | `GUNICORN_WORKERS` | Number of gunicorn worker processes. On high core-count machines the default formula (`cpu_count × 2 + 1`) can spawn dozens of processes; set this to a fixed value to stay within container PID limits. | `min(cpu_count × 2 + 1, 4)` | | `GUNICORN_WORKER_CAP` | Upper bound applied to the auto-calculated worker count. Set to `0` to remove the cap entirely and revert to the original `cpu_count × 2 + 1` behaviour. | `4` | | `GUNICORN_THREADS` | Number of threads per worker process. | `8` | diff --git a/entrypoint.sh b/entrypoint.sh index e8c3d7e6..c9452f5c 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -108,6 +108,10 @@ fi # ── Nginx ───────────────────────────────────────────────────────────────────── section "Nginx" +FIRESHARE_PORT=${FIRESHARE_PORT:-80} +log "Configuring nginx to listen on port ${FIRESHARE_PORT}" +sed "s/__FIRESHARE_PORT__/${FIRESHARE_PORT}/" \ + /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf log "Starting nginx" nginx -g 'daemon on;' log "Nginx ready"