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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ services:
environment:
- ADMIN_USERNAME=your-admin-username
- ADMIN_PASSWORD=your-admin-password
- SECRET_KEY=replace_with_random_string_can_be_anything
# A key for signing login cookies is generated on first start and kept in
# /data, so there is nothing to set here. See docs/Security.md
# The domain your instance is hosted at. e.x: demo.fireshare.net
# this is required for opengraph to work correctly for shared links.
- DOMAIN=
Expand Down
114 changes: 113 additions & 1 deletion app/client/src/components/admin/ImageFileManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import SearchIcon from '@mui/icons-material/Search'
import FolderIcon from '@mui/icons-material/Folder'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import LockIcon from '@mui/icons-material/Lock'
import PersonIcon from '@mui/icons-material/Person'
import LockOpenIcon from '@mui/icons-material/LockOpen'
import RefreshIcon from '@mui/icons-material/Refresh'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
Expand Down Expand Up @@ -305,6 +306,9 @@ export default function ImageFileManager({ setAlert }) {
const [moveModalOpen, setMoveModalOpen] = useState(false)
const [createFolderDialogOpen, setCreateFolderDialogOpen] = useState(false)
const [renameDialogOpen, setRenameDialogOpen] = useState(false)
const [uploaderDialogOpen, setUploaderDialogOpen] = useState(false)
const [uploaderChoice, setUploaderChoice] = useState(null)
const [uploaderOptions, setUploaderOptions] = useState([])
const [colVisAnchor, setColVisAnchor] = useState(null)

// Rename form state
Expand Down Expand Up @@ -493,7 +497,7 @@ export default function ImageFileManager({ setAlert }) {
setActionLoading(true)
try {
const { data } = await Api().post(endpoint, body)
const updatedCount = (data.updated ?? data.moved ?? data.deleted ?? []).length
const updatedCount = data.updated_images ?? (data.updated ?? data.moved ?? data.deleted ?? []).length
const errorCount = (data.errors ?? []).length
if (errorCount > 0) {
setAlert({
Expand Down Expand Up @@ -597,6 +601,23 @@ export default function ImageFileManager({ setAlert }) {
}
}

useEffect(() => {
if (!uploaderDialogOpen) return
Api()
.get('/api/admin/uploaders')
.then((res) => setUploaderOptions(res.data.users || []))
.catch(() => setUploaderOptions([]))
}, [uploaderDialogOpen])

const handleSetUploader = async () => {
const ok = await runBulkAction(
'/api/admin/image-files/bulk-set-uploader',
{ image_ids: [...selected], username: uploaderChoice },
uploaderChoice ? `Attributed to ${uploaderChoice}` : 'Uploader cleared',
)
if (ok) setUploaderDialogOpen(false)
}

const handleSetPrivacy = async (isPrivate) => {
await runBulkAction(
'/api/admin/image-files/bulk-set-privacy',
Expand Down Expand Up @@ -709,6 +730,26 @@ export default function ImageFileManager({ setAlert }) {
</IconButton>
</span>
</Tooltip>
<Tooltip title="Set uploader">
<span>
<IconButton
size="small"
disabled={onlyEmptyFoldersSelected}
onClick={() => {
setUploaderChoice(null)
setUploaderDialogOpen(true)
}}
sx={{
border: '1px solid #3399FF44',
borderRadius: 1,
color: '#7FBFFF',
'&:hover': { bgcolor: '#3399FF12' },
}}
>
<PersonIcon sx={{ fontSize: 20 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Rename">
<span>
<IconButton
Expand Down Expand Up @@ -810,6 +851,28 @@ export default function ImageFileManager({ setAlert }) {
</Button>
</span>
</Tooltip>
<Tooltip title="Attribute selected images to a user">
<span>
<Button
size="small"
variant="outlined"
disabled={onlyEmptyFoldersSelected}
startIcon={<PersonIcon />}
onClick={() => {
setUploaderChoice(null)
setUploaderDialogOpen(true)
}}
sx={{
textTransform: 'none',
borderColor: '#3399FF44',
color: '#7FBFFF',
'&:hover': { borderColor: '#3399FF99', bgcolor: '#3399FF12' },
}}
>
Uploader
</Button>
</span>
</Tooltip>
<Tooltip title="Rename selected images">
<span>
<Button
Expand Down Expand Up @@ -1695,6 +1758,55 @@ export default function ImageFileManager({ setAlert }) {
</Button>
</DialogActions>
</Dialog>

{/* ── Set Uploader modal ── */}
<Dialog
open={uploaderDialogOpen}
onClose={() => !actionLoading && setUploaderDialogOpen(false)}
fullWidth
maxWidth="xs"
PaperProps={{ sx: dialogPaperSx }}
>
<DialogTitle sx={dialogTitleSx}>Set uploader</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: '#FFFFFFAA', fontSize: 13.5, mb: 2 }}>
Attribute {selected.size} selected image{selected.size === 1 ? '' : 's'} to an account.
This is how library content indexed from disk gets an owner, so it appears on their
profile and comes under their edit and delete permissions.
</DialogContentText>
<Select
options={[
{ value: null, label: 'No uploader (unattributed)' },
...uploaderOptions.map((u) => ({
value: u.username,
label: u.name === u.username ? `@${u.username}` : `${u.name} (@${u.username})`,
})),
]}
value={
uploaderChoice === null
? { value: null, label: 'No uploader (unattributed)' }
: uploaderOptions
.filter((u) => u.username === uploaderChoice)
.map((u) => ({
value: u.username,
label: u.name === u.username ? `@${u.username}` : `${u.name} (@${u.username})`,
}))[0] || null
}
onChange={(opt) => setUploaderChoice(opt ? opt.value : null)}
styles={selectFolderTheme}
menuPortalTarget={document.body}
placeholder="Choose an account..."
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button onClick={() => setUploaderDialogOpen(false)} disabled={actionLoading} sx={{ color: '#B2BAC2' }}>
Cancel
</Button>
<Button onClick={handleSetUploader} variant="contained" disabled={actionLoading}>
{actionLoading ? 'Applying...' : 'Apply'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
2 changes: 1 addition & 1 deletion app/client/src/components/admin/VideoFileManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ export default function VideoFileManager({ setAlert }) {
setActionLoading(true)
try {
const { data } = await Api().post(endpoint, body)
const updatedCount = (data.updated ?? data.moved ?? data.deleted ?? []).length
const updatedCount = data.updated_videos ?? (data.updated ?? data.moved ?? data.deleted ?? []).length
const errorCount = (data.errors ?? []).length
if (errorCount > 0) {
setAlert({
Expand Down
8 changes: 5 additions & 3 deletions app/client/src/components/forms/LoginForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ const LoginForm = function () {
await completeLogin()
} catch (err) {
const status = err.response?.status
// 429 carries the throttle's own "try again in N seconds" text, which is the
// only way the user finds out why a correct password is being refused.
const showsServerMessage = status === 401 || status === 403 || status === 429
setAlert({
type: status === 401 ? 'warning' : 'error',
message:
status === 401 || status === 403 ? errorMessage(err) : 'An unknown error occurred while trying to log in.',
type: status === 401 || status === 429 ? 'warning' : 'error',
message: showsServerMessage ? errorMessage(err) : 'An unknown error occurred while trying to log in.',
open: true,
})
setLoading(false)
Expand Down
49 changes: 47 additions & 2 deletions app/server/fireshare/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,64 @@ def create_app(init_schedule=False):
ldap_retired.abort_if_ldap_configured(logger)

app = Flask(__name__, static_url_path='', static_folder='build', template_folder='build')
CORS(app, supports_credentials=True)

# Cross-origin access is off unless an operator names the origins that need it.
# It used to be CORS(app, supports_credentials=True) with no origins, which
# flask-cors reads as "any origin": it echoed whatever Origin arrived back as
# Access-Control-Allow-Origin next to Access-Control-Allow-Credentials: true,
# so any site a signed-in user visited could call the API as them. Fireshare
# serves its frontend from this same origin, so nothing needs this by default.
cors_origins = [o.strip() for o in os.getenv('CORS_ORIGINS', '').split(',') if o.strip()]
if '*' in cors_origins:
logger.warning(
"CORS_ORIGINS=* cannot be combined with credentialed requests and has been "
"ignored. List the origins that need access explicitly."
)
cors_origins = [o for o in cors_origins if o != '*']
if cors_origins:
CORS(app, supports_credentials=True, origins=cors_origins)
logger.info(f"CORS enabled for: {', '.join(cors_origins)}")

if 'DATA_DIRECTORY' not in os.environ:
raise Exception("DATA_DIRECTORY not found in environment")

app.config['ENVIRONMENT'] = os.getenv('ENVIRONMENT')
app.config['DOMAIN'] = os.getenv('DOMAIN')
app.config['THUMBNAIL_VIDEO_LOCATION'] = int(os.getenv('THUMBNAIL_VIDEO_LOCATION') or 0)

app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', secrets.token_hex(32))
# Both of these shipped filled in — one in docker-compose.yml, one in the README's
# compose example — so instances that never edited the line were signing cookies
# with a key published in this repo. A signing key is worth nothing once public,
# so treat either as if it were unset.
PUBLISHED_SECRET_KEYS = {
'replace_this_with_some_random_string',
'replace_with_random_string_can_be_anything',
}
_secret_key = os.getenv('SECRET_KEY') or ''
if _secret_key in PUBLISHED_SECRET_KEYS:
logger.warning(
"SECRET_KEY is set to an example value published in the Fireshare docs. That key "
"is public, so it is being ignored in favour of a generated one. Remove the line "
"from your compose file; sessions will not survive a restart until you do."
)
_secret_key = ''
app.config['SECRET_KEY'] = _secret_key or secrets.token_hex(32)

from datetime import timedelta
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)

# SameSite was never set, leaving both cookies on whatever the browser happened
# to default to. Lax is stated explicitly so a cross-site request cannot carry
# them regardless of that default. Secure is opt-in because plenty of instances
# are reached over plain HTTP on a LAN, where it would lock users out entirely.
secure_cookies = env_bool('SECURE_COOKIES')
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['SESSION_COOKIE_SECURE'] = secure_cookies
app.config['REMEMBER_COOKIE_HTTPONLY'] = True
app.config['REMEMBER_COOKIE_SAMESITE'] = 'Lax'
app.config['REMEMBER_COOKIE_SECURE'] = secure_cookies

app.config['DATA_DIRECTORY'] = os.getenv('DATA_DIRECTORY')
app.config['VIDEO_DIRECTORY'] = os.getenv('VIDEO_DIRECTORY')
app.config['PROCESSED_DIRECTORY'] = os.getenv('PROCESSED_DIRECTORY')
Expand Down
2 changes: 0 additions & 2 deletions app/server/fireshare/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import os
from flask import Blueprint
from flask_cors import CORS

templates_path = os.environ.get('TEMPLATE_PATH') or 'templates'
api = Blueprint('api', __name__, template_folder=templates_path)
CORS(api, supports_credentials=True)

from . import transcoding, scan, misc, admin, video, upload, game, tag, image, folder, profile, users # noqa: E402,F401
28 changes: 21 additions & 7 deletions app/server/fireshare/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from ..models import Video, VideoInfo, VideoView, GameMetadata, VideoGameLink, VideoTagLink, Image, ImageInfo, ImageGameLink, ImageTagLink, ImageView, TranscodeJob, MediaFolder
from .. import permissions as perms
from . import api
from .helpers import cancel_pending_transcode_jobs, delete_video_files
from .helpers import cancel_pending_transcode_jobs, delete_video_files, resolve_media_subfolder
from .transcoding import _is_pid_running
from .scan import _game_scan_state
from .decorators import admin_required, demo_restrict
Expand Down Expand Up @@ -462,7 +462,10 @@ def bulk_move_files():

paths = current_app.config['PATHS']
video_path = paths['video']
target_folder_path = video_path / target_folder
resolved = resolve_media_subfolder(video_path, target_folder)
if not resolved:
return Response(status=400, response=f"Folder '{target_folder}' is not inside the video directory.")
target_folder_path, target_folder = resolved

if not target_folder_path.is_dir():
return Response(status=400, response=f"Folder '{target_folder}' does not exist.")
Expand All @@ -477,7 +480,7 @@ def bulk_move_files():

old_file_path = video_path / video.path
filename = Path(video.path).name
new_path = f"{target_folder}/{filename}"
new_path = f"{target_folder}/{filename}" if target_folder else filename
new_file_path = video_path / new_path

if old_file_path.resolve() == new_file_path.resolve():
Expand All @@ -497,7 +500,12 @@ def bulk_move_files():
os.symlink(new_file_path.absolute(), link_path)

video.path = new_path
video.folder_id = _get_or_create_media_folder(target_folder.split('/')[0], "video").id
# Media sitting directly in the root carries no folder row, matching how
# the disk scan records it.
video.folder_id = (
_get_or_create_media_folder(target_folder.split('/')[0], "video").id
if target_folder else None
)

from ..models import FolderRule
folder_rule = FolderRule.query.filter_by(folder_path=target_folder).first()
Expand Down Expand Up @@ -1045,7 +1053,10 @@ def bulk_move_images():
return Response(status=503, response='IMAGE_DIRECTORY is not configured.')

image_path = Path(image_directory)
target_folder_path = image_path / target_folder
resolved = resolve_media_subfolder(image_path, target_folder)
if not resolved:
return Response(status=400, response=f"Folder '{target_folder}' is not inside the image directory.")
target_folder_path, target_folder = resolved
paths = current_app.config['PATHS']

if not target_folder_path.is_dir():
Expand All @@ -1061,7 +1072,7 @@ def bulk_move_images():

old_file_path = image_path / img.path
filename = Path(img.path).name
new_path = f"{target_folder}/{filename}"
new_path = f"{target_folder}/{filename}" if target_folder else filename
new_file_path = image_path / new_path

if old_file_path.resolve() == new_file_path.resolve():
Expand All @@ -1081,7 +1092,10 @@ def bulk_move_images():
os.symlink(new_file_path.absolute(), link_path)

img.path = new_path
img.folder_id = _get_or_create_media_folder(target_folder.split('/')[0], "image").id
img.folder_id = (
_get_or_create_media_folder(target_folder.split('/')[0], "image").id
if target_folder else None
)
db.session.commit()
results['moved'].append(img_id)
except Exception as e:
Expand Down
3 changes: 3 additions & 0 deletions app/server/fireshare/api/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def game_json_with_assets(game):


@api.route('/api/steamgrid/search', methods=["GET"])
@login_required
def search_steamgrid():
query = request.args.get('query')
if not query:
Expand All @@ -69,6 +70,7 @@ def search_steamgrid():


@api.route('/api/steamgrid/game/<int:game_id>/assets', methods=["GET"])
@login_required
def get_steamgrid_assets(game_id):
api_key = get_steamgriddb_api_key()
if not api_key:
Expand All @@ -81,6 +83,7 @@ def get_steamgrid_assets(game_id):


@api.route('/api/steamgrid/game/<int:game_id>/assets/options', methods=["GET"])
@login_required
def get_steamgrid_asset_options(game_id):
api_key = get_steamgriddb_api_key()
if not api_key:
Expand Down
Loading
Loading