Fling User Download

File listing and dynamic download button
This commit is contained in:
Armin Friedl 2020-06-04 22:28:30 +02:00
parent c1171e8376
commit 4d9b3d0d87
Signed by: armin
GPG key ID: 48C726EEE7FBCBC8
10 changed files with 279 additions and 15 deletions

View file

@ -15,6 +15,8 @@ public class FlingDto {
private Long id; private Long id;
private Instant creationTime;
@JsonIgnore @JsonIgnore
private Boolean directDownload; private Boolean directDownload;
@ -91,4 +93,9 @@ public class FlingDto {
return expiration; return expiration;
} }
@JsonProperty("creationTime")
public Long getJsonUploadTime() {
return creationTime.toEpochMilli();
}
} }

View file

@ -1,14 +1,18 @@
package net.friedl.fling.security; package net.friedl.fling.security;
import java.util.NoSuchElementException;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import net.friedl.fling.security.authentication.FlingToken; import net.friedl.fling.security.authentication.FlingToken;
import net.friedl.fling.security.authentication.dto.UserAuthDto; import net.friedl.fling.security.authentication.dto.UserAuthDto;
import net.friedl.fling.service.FlingService; import net.friedl.fling.service.FlingService;
@Slf4j
@Service @Service
public class AuthorizationService { public class AuthorizationService {
private FlingService flingService; private FlingService flingService;
@ -18,11 +22,13 @@ public class AuthorizationService {
this.flingService = flingService; this.flingService = flingService;
} }
public boolean allowUpload(Long flingId) { public boolean allowUpload(Long flingId, FlingToken authentication) {
return flingService if (authentication.getGrantedFlingAuthority().getAuthority().equals(FlingAuthority.FLING_OWNER.name())) {
.findFlingById(flingId) return true;
.orElseThrow() }
.getAllowUpload();
return flingService.findFlingById(flingId).orElseThrow().getAllowUpload()
&& authentication.getGrantedFlingAuthority().getFlingId().equals(flingId);
} }
public boolean allowFlingAccess(UserAuthDto userAuth, String shareUrl) { public boolean allowFlingAccess(UserAuthDto userAuth, String shareUrl) {
@ -30,7 +36,7 @@ public class AuthorizationService {
} }
public boolean allowFlingAccess(Long flingId, FlingToken authentication) { public boolean allowFlingAccess(Long flingId, FlingToken authentication) {
if(authentication.getGrantedFlingAuthority().getAuthority().equals(FlingAuthority.FLING_OWNER.name())) { if (authentication.getGrantedFlingAuthority().getAuthority().equals(FlingAuthority.FLING_OWNER.name())) {
return true; return true;
} }
@ -38,14 +44,22 @@ public class AuthorizationService {
} }
public boolean allowFlingAccess(FlingToken authentication, HttpServletRequest request) { public boolean allowFlingAccess(FlingToken authentication, HttpServletRequest request) {
if(authentication.getGrantedFlingAuthority().getAuthority().equals(FlingAuthority.FLING_OWNER.name())) { if (authentication.getGrantedFlingAuthority().getAuthority().equals(FlingAuthority.FLING_OWNER.name())) {
return true; return true;
} }
var shareId = request.getParameter("shareId"); var shareId = request.getParameter("shareId");
var flingId = shareId != null
Long flingId;
try {
flingId = shareId != null
? flingService.findFlingByShareId(shareId).orElseThrow().getId() ? flingService.findFlingByShareId(shareId).orElseThrow().getId()
: request.getParameter("flingId"); : Long.parseLong(request.getParameter("flingId"));
} catch (NumberFormatException | NoSuchElementException e) {
log.warn("Invalid shareId [shareId=\"{}\"] or flingId [flingId=\"{}\"] found", request.getParameter("shareId"), request.getParameter("flingId"));
flingId = null;
}
return authentication.getGrantedFlingAuthority().getFlingId().equals(flingId); return authentication.getGrantedFlingAuthority().getFlingId().equals(flingId);
} }

View file

@ -68,7 +68,7 @@ public class FlingWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
.and() .and()
.authorizeRequests() .authorizeRequests()
.antMatchers(HttpMethod.POST, "/api/artifacts/{flingId}/**") .antMatchers(HttpMethod.POST, "/api/artifacts/{flingId}/**")
.access("hasAuthority('"+FlingAuthority.FLING_USER.name()+"') and @authorizationService.allowUpload(#flingId)") .access("@authorizationService.allowUpload(#flingId, authentication)")
.and() .and()
.authorizeRequests() .authorizeRequests()
// TODO: This is still insecure since URLs are not encrypted // TODO: This is still insecure since URLs are not encrypted
@ -82,6 +82,12 @@ public class FlingWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
.regexMatchers(HttpMethod.GET, "\\/api\\/fling\\?(shareId=|flingId=)[a-zA-Z0-9]+") .regexMatchers(HttpMethod.GET, "\\/api\\/fling\\?(shareId=|flingId=)[a-zA-Z0-9]+")
.access("@authorizationService.allowFlingAccess(authentication, request)") .access("@authorizationService.allowFlingAccess(authentication, request)")
.and() .and()
.authorizeRequests()
// TODO: Security by request parameters is just not well supported with spring security
// TODO: Change API
.regexMatchers(HttpMethod.GET, "\\/api\\/artifacts\\?(shareId=|flingId=)[a-zA-Z0-9]+")
.access("@authorizationService.allowFlingAccess(authentication, request)")
.and()
.authorizeRequests() .authorizeRequests()
.antMatchers(HttpMethod.GET, "/api/fling/{flingId}/**") .antMatchers(HttpMethod.GET, "/api/fling/{flingId}/**")
.access("@authorizationService.allowFlingAccess(#flingId, authentication)") .access("@authorizationService.allowFlingAccess(#flingId, authentication)")

View file

@ -25,6 +25,8 @@ fling:
allowed-origins: allowed-origins:
- "https://friedl.net" - "https://friedl.net"
- "http://localhost:3000" - "http://localhost:3000"
- "http://localhost:5000"
- "http://10.0.2.2:5000"
admin-user: "${FLING_ADMIN_USER:admin}" admin-user: "${FLING_ADMIN_USER:admin}"
admin-password: "${FLING_ADMIN_PASSWORD:123}" admin-password: "${FLING_ADMIN_PASSWORD:123}"
signing-key: "${FLING_SIGNING_KEY:changeitchangeitchangeitchangeit}" signing-key: "${FLING_SIGNING_KEY:changeitchangeitchangeitchangeit}"

View file

@ -8,6 +8,7 @@
"@testing-library/user-event": "^7.2.1", "@testing-library/user-event": "^7.2.1",
"axios": "^0.19.2", "axios": "^0.19.2",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"core-js": "^3.6.5",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"loglevel": "^1.6.8", "loglevel": "^1.6.8",
"node-sass": "^4.14.0", "node-sass": "^4.14.0",

View file

@ -44,8 +44,12 @@ export default function LandingPage() {
<h5>I got a code...</h5> <h5>I got a code...</h5>
<img src={view_fling} alt="Fling view" /> <img src={view_fling} alt="Fling view" />
<div className="input-group mt-2"> <div className="input-group mt-2">
<form onSubmit={openFling}>
<div className="input-group">
<input type="text" className="form-input input-sm" value={shareId} onChange={changeShareId} placeholder="My code" /> <input type="text" className="form-input input-sm" value={shareId} onChange={changeShareId} placeholder="My code" />
<button className="btn btn-secondary input-group-btn btn-sm" onClick={openFling}>Open</button> <input type="submit" className="btn btn-secondary input-group-btn btn-sm" onClick={openFling} value="Open"/>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>

View file

@ -6,6 +6,7 @@ import {useParams, BrowserRouter} from 'react-router-dom';
import {flingClient} from '../../util/flingclient'; import {flingClient} from '../../util/flingclient';
import DirectDownload from './DirectDownload'; import DirectDownload from './DirectDownload';
import FlingUserList from './FlingUserList';
export default function FlingUser() { export default function FlingUser() {
let { shareId } = useParams(); let { shareId } = useParams();
@ -18,7 +19,7 @@ export default function FlingUser() {
return( return(
<div> <div>
{fling.sharing && fling.sharing.directDownload ? <DirectDownload fling={fling} />: ""} {fling.sharing && fling.sharing.directDownload ? <DirectDownload fling={fling} />: <FlingUserList fling={fling} />}
</div> </div>
); );
} }

View file

@ -0,0 +1,163 @@
import log from 'loglevel';
import React, {useState, useEffect, useRef} from 'react';
import {useParams, BrowserRouter} from 'react-router-dom';
import {flingClient, artifactClient} from '../../util/flingclient';
function Artifacts(props) {
let [artifacts, setArtifacts] = useState([]);
useEffect(() => {
if(!props.fling) return;
artifactClient.getArtifacts(props.fling.id)
.then((artifacts) => setArtifacts(artifacts));
}, [props.fling]);
function renderArtifact(artifact) {
function readableBytes(bytes) {
if(bytes <= 0) return "0 KB";
var i = Math.floor(Math.log(bytes) / Math.log(1024)),
sizes = ['Byte', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
return (bytes / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + sizes[i];
}
function localizedDate(t) {
let d = new Date(t);
return d.toLocaleDateString();
}
function getArtifactInfo() {
return `${readableBytes(artifact.size)}, ${localizedDate(artifact.uploadTime)}`;
}
return(
<div className="user-list-artifact">
<div className="container">
<div className="columns">
<div className="column col-8 col-sm-12">
{artifact.name}<br />
</div>
<div className="column col-2 col-sm-6">
<div className="text-gray">{readableBytes(artifact.size)}</div>
</div>
<div className="column col-2 col-sm-6">
<div className="text-gray float-right">{localizedDate(artifact.uploadTime)}</div>
</div>
</div>
</div>
</div>
);
}
return (
<div>
{artifacts.map(renderArtifact)}
</div>
);
}
export default function FlingUserList(props) {
let iframeContainer = useRef(null);
let [infoText, setInfoText] = useState("");
let [inProgress, setInProgress] = useState(false);
let [downloadUrl, setDownloadUrl] = useState("");
useEffect(() => flingInfo(props.fling.id), [props.fling.id]);
function flingInfo(flingId) {
if(!flingId) return;
function readableBytes(bytes) {
if(bytes <= 0) return "0 KB";
var i = Math.floor(Math.log(bytes) / Math.log(1024)),
sizes = ['Byte', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
return (bytes / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + sizes[i];
}
function localizedDate(t) {
let d = new Date(t);
return d.toLocaleDateString();
}
artifactClient.getArtifacts(flingId)
.then((artifacts) => {
let totalSize = 0;
let countArtifacts = 0;
for(let artifact of artifacts) {
totalSize += artifact.size;
countArtifacts++;
}
setInfoText(`${localizedDate(props.fling.creationTime)} - ${countArtifacts} files - ${readableBytes(totalSize)}`);
});
}
function handleDownload(ev) {
ev.preventDefault();
setInProgress(true);
flingClient.packageFling(props.fling.id)
.then(downloadUrl => {
// We need this iframe hack because with a regular href, while
// the browser downloads the file fine, it also reloads the page, hence
// loosing all logs and state
let frame = document.createElement("iframe");
frame.src = downloadUrl;
iframeContainer.current.appendChild(frame);
setDownloadUrl(downloadUrl);
setInProgress(false);
});
}
return(
<>
<div className="container-center">
<div className="col-6 col-xl-8 col-lg-10 col-md-12">
<h3 className="ml-2"> {props.fling.name} </h3>
<div className="ml-2 text-gray">{infoText}</div>
<div className="card">
<ul className="tab mx-2">
<li className="tab-item active">
<a>Files</a>
</li>
<li className="tab-item">
<a>Upload</a>
</li>
<li className="tab-item tab-action">
<div className="card-title">
{inProgress
? <button className="m-2 btn btn-xs btn-secondary float-right user-list-loading" disabled="true"
onClick={(ev) => ev.preventDefault()}>
<div className="loading" /> Packaging
</button>
: <button className="m-2 btn btn-xs btn-secondary float-right" onClick={handleDownload}>Download</button>
}
</div>
</li>
</ul>
<div className="card-body">
<Artifacts fling={props.fling} />
</div>
</div>
<div className="d-hide" ref={iframeContainer} />
</div>
</div>
</>
);
}

View file

@ -0,0 +1,25 @@
import log from 'loglevel';
import React, {useState, useEffect} from 'react';
import {useParams, BrowserRouter} from 'react-router-dom';
import {flingClient} from '../../util/flingclient';
import DirectDownload from './DirectDownload';
export default function FlingUserUpload(props) {
let { shareId } = useParams();
let [fling, setFling] = useState({});
useEffect(() => {
flingClient.getFlingByShareId(shareId)
.then(f => setFling(f));
}, [shareId]);
return(
<div>
{fling.sharing && fling.sharing.directDownload ? <DirectDownload fling={fling} />: ""}
</div>
);
}

View file

@ -11,6 +11,10 @@ body {
background-color: $canvas-base-color; background-color: $canvas-base-color;
} }
.card {
@include shadow;
}
/*********\ /*********\
| Login | | Login |
\*********/ \*********/
@ -312,3 +316,40 @@ tbody td {
width: 12rem; width: 12rem;
text-align: center; text-align: center;
} }
/*****************\
| FlingUserList |
\*****************/
.user-list-loading {
.loading {
display: inline-block;
&::after {
position: relative;
left: unset;
top: unset;
display: inline-block;
margin: unset;
animation-duration: 1s;
}
}
}
.user-list-artifact {
word-break: break-all;
&:first-child {
margin-top: 0.4rem;
}
&:not(:last-child)::after {
@extend .user-list-divider;
content: '';
}
}
.user-list-divider {
@extend .divider;
transform: scale(0.98, 1);
}