diff --git a/client/src/actions/publish.js b/client/src/actions/publish.js index 552e2aff..91e44fd5 100644 --- a/client/src/actions/publish.js +++ b/client/src/actions/publish.js @@ -14,6 +14,19 @@ export function clearFile () { }; } +export function setUpdateTrue () { + return { + type: actions.SET_UPDATE_TRUE, + }; +} + +export function setHasChanged (status) { + return { + type: actions.SET_HAS_CHANGED, + data: status, + }; +} + export function updateMetadata (name, value) { return { type: actions.METADATA_UPDATE, @@ -31,6 +44,13 @@ export function updateClaim (value) { }; }; +export function abandonClaim (data) { + return { + type: actions.ABANDON_CLAIM, + data, + }; +}; + export function setPublishInChannel (channel) { return { type: actions.SET_PUBLISH_IN_CHANNEL, diff --git a/client/src/actions/show.js b/client/src/actions/show.js index 1bac1310..807d4f02 100644 --- a/client/src/actions/show.js +++ b/client/src/actions/show.js @@ -105,6 +105,13 @@ export function updateAssetViewsInList (id, claimId, claimViews) { }; } +export function removeAsset (data) { + return { + type: actions.ASSET_REMOVE, + data, + }; +} + // channel actions export function addNewChannelToChannelList (id, name, shortId, longId, claimsData) { @@ -129,7 +136,7 @@ export function onUpdateChannelClaims (channelKey, name, longId, page) { export function updateChannelClaims (channelListId, claimsData) { return { - type: actions.CHANNEL_CLAIMS_UPDATE_SUCCESS, + type: actions.CHANNEL_CLAIMS_UPDATE_SUCCEEDED, data: {channelListId, claimsData}, }; } diff --git a/client/src/api/assetApi.js b/client/src/api/assetApi.js index 5290d19f..0750b572 100644 --- a/client/src/api/assetApi.js +++ b/client/src/api/assetApi.js @@ -42,3 +42,15 @@ export function getClaimViews (claimId) { const url = `/api/claim/views/${claimId}`; return Request(url); } + +export function doAbandonClaim (claimId) { + const params = { + method : 'POST', + body : JSON.stringify({claimId}), + headers: new Headers({ + 'Content-Type': 'application/json', + }), + credentials: 'include', + }; + return Request('/api/claim/abandon', params); +} diff --git a/client/src/app.js b/client/src/app.js index c4456ffe..e2bfd46e 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -10,6 +10,7 @@ import ContentPageWrapper from '@pages/ContentPageWrapper'; import FourOhFourPage from '@pages/FourOhFourPage'; import MultisitePage from '@pages/MultisitePage'; import PopularPage from '@pages/PopularPage'; +import EditPage from '@pages/EditPage'; const App = () => { return ( @@ -21,6 +22,7 @@ const App = () => { + diff --git a/client/src/channels/publish.js b/client/src/channels/publish.js index bdbfbf19..0a89feb4 100644 --- a/client/src/channels/publish.js +++ b/client/src/channels/publish.js @@ -1,8 +1,8 @@ import {buffers, END, eventChannel} from 'redux-saga'; -export const makePublishRequestChannel = (fd) => { +export const makePublishRequestChannel = (fd, isUpdate) => { return eventChannel(emitter => { - const uri = '/api/claim/publish'; + const uri = `/api/claim/${isUpdate ? 'update' : 'publish'}`; const xhr = new XMLHttpRequest(); // add event listeners const onLoadStart = () => { diff --git a/client/src/components/DropzoneInstructionsDisplay/index.jsx b/client/src/components/DropzoneInstructionsDisplay/index.jsx index 3f13c69f..eb49ae98 100644 --- a/client/src/components/DropzoneInstructionsDisplay/index.jsx +++ b/client/src/components/DropzoneInstructionsDisplay/index.jsx @@ -2,11 +2,14 @@ import React from 'react'; import FormFeedbackDisplay from '@components/FormFeedbackDisplay'; import Row from '@components/Row'; -const DropzoneInstructionsDisplay = ({fileError}) => { +const DropzoneInstructionsDisplay = ({fileError, message}) => { + if (!message) { + message = 'Drag & drop image or video here to publish'; + } return (
-

Drag & drop image or video here to publish

+

{message}

OR

diff --git a/client/src/components/DropzonePreviewImage/index.jsx b/client/src/components/DropzonePreviewImage/index.jsx index d94d976a..d62abaeb 100644 --- a/client/src/components/DropzonePreviewImage/index.jsx +++ b/client/src/components/DropzonePreviewImage/index.jsx @@ -10,7 +10,12 @@ class PublishPreview extends React.Component { }; } componentDidMount () { - this.setPreviewImageSource(this.props.file); + const { isUpdate, sourceUrl, file } = this.props; + if (isUpdate && sourceUrl) { + this.setState({ imgSource: sourceUrl }); + } else { + this.setPreviewImageSource(file); + } } componentWillReceiveProps (newProps) { if (newProps.file !== this.props.file) { @@ -54,8 +59,10 @@ class PublishPreview extends React.Component { PublishPreview.propTypes = { dimPreview: PropTypes.bool.isRequired, - file : PropTypes.object.isRequired, + file : PropTypes.object, thumbnail : PropTypes.object, + isUpdate : PropTypes.bool, + sourceUrl : PropTypes.string, }; export default PublishPreview; diff --git a/client/src/components/PublishLicenseInput/index.jsx b/client/src/components/PublishLicenseInput/index.jsx index c4ee43f5..6ef59418 100644 --- a/client/src/components/PublishLicenseInput/index.jsx +++ b/client/src/components/PublishLicenseInput/index.jsx @@ -16,7 +16,7 @@ const PublishLicenseInput = ({ handleSelect }) => { className='select select--primary' onChange={handleSelect} > - + diff --git a/client/src/components/PublishPreview/index.jsx b/client/src/components/PublishPreview/index.jsx index 9f7240cf..817b5c8c 100644 --- a/client/src/components/PublishPreview/index.jsx +++ b/client/src/components/PublishPreview/index.jsx @@ -7,9 +7,11 @@ import Row from '@components/Row'; class PublishPreview extends React.Component { render () { + const { isUpdate, uri } = this.props; return (
+ {isUpdate && uri && (

{`Editing ${uri}`}

)}
{ +const mapStateToProps = (props) => { + const {show} = props; // select error and status const error = show.displayAsset.error; const status = show.displayAsset.status; diff --git a/client/src/containers/AssetDisplay/view.jsx b/client/src/containers/AssetDisplay/view.jsx index d5b37b82..7d77f951 100644 --- a/client/src/containers/AssetDisplay/view.jsx +++ b/client/src/containers/AssetDisplay/view.jsx @@ -2,6 +2,42 @@ import React from 'react'; import Row from '@components/Row'; import ProgressBar from '@components/ProgressBar'; import { LOCAL_CHECK, UNAVAILABLE, ERROR, AVAILABLE } from '../../constants/asset_display_states'; +import createCanonicalLink from '../../../../utils/createCanonicalLink'; + +class AvailableContent extends React.Component { + render () { + const {contentType, sourceUrl, name, thumbnail} = this.props; + switch (contentType) { + case 'image/jpeg': + case 'image/jpg': + case 'image/png': + case 'image/gif': + return ( + {name} + ); + case 'video/mp4': + return ( + + ); + default: + return ( +

Unsupported content type

+ ); + } + } +} class AssetDisplay extends React.Component { componentDidMount () { @@ -9,8 +45,15 @@ class AssetDisplay extends React.Component { this.props.onFileRequest(name, claimId); } render () { - const { status, error, asset: { claimData: { name, claimId, contentType, fileExt, thumbnail } } } = this.props; - const sourceUrl = `/${claimId}/${name}.${fileExt}`; + const { status, error, asset } = this.props; + const { name, claimData: { claimId, contentType, thumbnail, outpoint } } = asset; + // the outpoint is added to force the browser to re-download the asset after an update + // issue: https://github.com/lbryio/spee.ch/issues/607 + let fileExt; + if (typeof contentType === 'string') { + fileExt = contentType.split('/')[1] || 'jpg'; + } + const sourceUrl = `${createCanonicalLink({ asset: asset.claimData })}.${fileExt}?${outpoint}`; return (
{(status === LOCAL_CHECK) && @@ -36,37 +79,12 @@ class AssetDisplay extends React.Component {
} {(status === AVAILABLE) && - (() => { - switch (contentType) { - case 'image/jpeg': - case 'image/jpg': - case 'image/png': - case 'image/gif': - return ( - {name} - ); - case 'video/mp4': - return ( - - ); - default: - return ( -

Unsupported content type

- ); - } - })() + }
); diff --git a/client/src/containers/AssetInfo/index.js b/client/src/containers/AssetInfo/index.js index e2eb3a68..c4f34eb6 100644 --- a/client/src/containers/AssetInfo/index.js +++ b/client/src/containers/AssetInfo/index.js @@ -2,12 +2,20 @@ import { connect } from 'react-redux'; import View from './view'; import { selectAsset } from '../../selectors/show'; -const mapStateToProps = ({ show }) => { +const mapStateToProps = (props) => { + const {show} = props; // select asset const asset = selectAsset(show); + const editable = Boolean( + asset && + asset.claimData && + asset.claimData.channelName && + props.channel.loggedInChannel.name === asset.claimData.channelName + ); // return props return { asset, + editable, }; }; diff --git a/client/src/containers/AssetInfo/view.jsx b/client/src/containers/AssetInfo/view.jsx index af742617..625d3f82 100644 --- a/client/src/containers/AssetInfo/view.jsx +++ b/client/src/containers/AssetInfo/view.jsx @@ -13,10 +13,11 @@ import createCanonicalLink from '../../../../utils/createCanonicalLink'; class AssetInfo extends React.Component { render () { - const { asset } = this.props; - const { claimViews, claimData: { channelName, channelShortId, description, name, fileExt, contentType, thumbnail, host } } = asset; + const { editable, asset } = this.props; + const { claimViews, claimData } = asset; + const { channelName, claimId, channelShortId, description, name, fileExt, contentType, host } = claimData; - const canonicalUrl = createCanonicalLink({ asset: { ...asset.claimData, shortId: asset.shortId }}); + const canonicalUrl = createCanonicalLink({ asset: { ...claimData, shortId: asset.shortId }}); const assetCanonicalUrl = `${host}${canonicalUrl}`; let channelCanonicalUrl; @@ -29,6 +30,15 @@ class AssetInfo extends React.Component { } return (
+ {editable && ( + + } + content={{name}} + /> + + )} + {channelName && ( { - const { claimData: { title } } = selectAsset(show); +const mapStateToProps = (props) => { + const { claimData: { title } } = selectAsset(props.show); return { title, }; diff --git a/client/src/containers/AssetTitle/view.jsx b/client/src/containers/AssetTitle/view.jsx index a542c762..87e1e661 100644 --- a/client/src/containers/AssetTitle/view.jsx +++ b/client/src/containers/AssetTitle/view.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import Row from '@components/Row'; const AssetTitle = ({ title }) => { diff --git a/client/src/containers/ChannelClaimsDisplay/view.jsx b/client/src/containers/ChannelClaimsDisplay/view.jsx index 1f29b65b..8d4fb25e 100644 --- a/client/src/containers/ChannelClaimsDisplay/view.jsx +++ b/client/src/containers/ChannelClaimsDisplay/view.jsx @@ -36,7 +36,7 @@ class ChannelClaimsDisplay extends React.Component { ))}
diff --git a/client/src/containers/Dropzone/index.js b/client/src/containers/Dropzone/index.js index 13b2e5e0..dfd0f8cd 100644 --- a/client/src/containers/Dropzone/index.js +++ b/client/src/containers/Dropzone/index.js @@ -1,13 +1,29 @@ import { connect } from 'react-redux'; import { selectFile, updateError, clearFile } from '../../actions/publish'; +import { selectAsset } from '../../selectors/show'; import View from './view'; +import siteConfig from '@config/siteConfig.json'; +import createCanonicalLink from '../../../../utils/createCanonicalLink'; -const mapStateToProps = ({ publish }) => { - return { - file : publish.file, - thumbnail: publish.thumbnail, - fileError: publish.error.file, - }; +const { assetDefaults: { thumbnail: defaultThumbnail } } = siteConfig; + +const mapStateToProps = ({ show, publish: { file, thumbnail, fileError, isUpdate } }) => { + const obj = { file, thumbnail, fileError, isUpdate }; + let asset, name, claimId, fileExt, outpoint, sourceUrl; + if (isUpdate) { + asset = selectAsset(show); + const { claimData } = asset; + if (asset) { + obj.fileExt = claimData.contentType.split('/')[1]; + if (obj.fileExt === 'mp4') { + obj.sourceUrl = claimData.thumbnail ? claimData.thumbnail : defaultThumbnail; + } else { + ({fileExt, outpoint} = claimData); + obj.sourceUrl = `${createCanonicalLink({ asset: claimData })}.${fileExt}?${outpoint}`; + } + } + } + return obj; }; const mapDispatchToProps = dispatch => { diff --git a/client/src/containers/Dropzone/view.jsx b/client/src/containers/Dropzone/view.jsx index 517e9d15..436bac99 100644 --- a/client/src/containers/Dropzone/view.jsx +++ b/client/src/containers/Dropzone/view.jsx @@ -81,53 +81,70 @@ class Dropzone extends React.Component { } } render () { + const { dragOver, mouseOver, dimPreview } = this.state; + const { file, thumbnail, fileError, isUpdate, sourceUrl, fileExt } = this.props; return ( -
-
- -
-
- {this.props.file ? ( -
- + {isUpdate && fileExt === 'mp4' ? ( +

Video updates are currently disabled. This feature will be available soon. You can edit metadata.

+ ) : ( +
+
+ -
- { this.state.dragOver ? : null } - { this.state.mouseOver ? ( + +
+ {file || isUpdate ? ( +
+ {file ? ( + + ) : ( + + )} +
+ { dragOver ? : null } + { mouseOver ? ( + + ) : null } +
+
+ ) : ( + dragOver ? : ( - ) : null } -
+ ) + )}
- ) : ( - this.state.dragOver ? : ( - - ) - )} -
+
+ )}
); } diff --git a/client/src/containers/PublishDetails/index.js b/client/src/containers/PublishDetails/index.js index 7b880782..8c8eede0 100644 --- a/client/src/containers/PublishDetails/index.js +++ b/client/src/containers/PublishDetails/index.js @@ -1,16 +1,21 @@ import { connect } from 'react-redux'; -import { clearFile, startPublish } from '../../actions/publish'; +import { clearFile, startPublish, abandonClaim } from '../../actions/publish'; +import { selectAsset } from '../../selectors/show'; import View from './view'; -const mapStateToProps = ({ channel, publish }) => { +const mapStateToProps = ({ show, publish }) => { return { file: publish.file, + isUpdate: publish.isUpdate, + hasChanged: publish.hasChanged, + asset: selectAsset(show), }; }; const mapDispatchToProps = { clearFile, startPublish, + abandonClaim, }; export default connect(mapStateToProps, mapDispatchToProps)(View); diff --git a/client/src/containers/PublishDetails/view.jsx b/client/src/containers/PublishDetails/view.jsx index ac55e226..95a79edd 100644 --- a/client/src/containers/PublishDetails/view.jsx +++ b/client/src/containers/PublishDetails/view.jsx @@ -1,35 +1,76 @@ import React from 'react'; -import { withRouter } from 'react-router-dom'; +import {Link, withRouter} from 'react-router-dom'; import PublishUrlInput from '@containers/PublishUrlInput'; import PublishThumbnailInput from '@containers/PublishThumbnailInput'; import PublishMetadataInputs from '@containers/PublishMetadataInputs'; import ChannelSelect from '@containers/ChannelSelect'; import Row from '@components/Row'; +import Label from '@components/Label'; +import RowLabeled from '@components/RowLabeled'; import ButtonPrimaryJumbo from '@components/ButtonPrimaryJumbo'; import ButtonTertiary from '@components/ButtonTertiary'; +import ButtonSecondary from '@components/ButtonSecondary'; import SpaceAround from '@components/SpaceAround'; import PublishFinePrint from '@components/PublishFinePrint'; +import { SAVE } from '../../constants/confirmation_messages'; class PublishDetails extends React.Component { constructor (props) { super(props); this.onPublishSubmit = this.onPublishSubmit.bind(this); + this.abandonClaim = this.abandonClaim.bind(this); + this.onCancel = this.onCancel.bind(this); } onPublishSubmit () { this.props.startPublish(this.props.history); } + abandonClaim () { + const {asset, history} = this.props; + if (asset) { + const {claimData} = asset; + this.props.abandonClaim({claimData, history}); + } + } + onCancel () { + const { isUpdate, hasChanged, clearFile, history } = this.props; + if (isUpdate || !hasChanged) { + history.push('/'); + } else { + if (confirm(SAVE)) { + clearFile(); + } + } + } render () { + const {file, isUpdate, asset} = this.props; return (
- - - + {isUpdate ? (asset && ( + + + } + content={ + + {asset.claimData.channelName} + + } + /> + + )) : ( + + + + - - - + + + + + )} - { this.props.file.type === 'video/mp4' && ( + { file && file.type === 'video/mp4' && ( @@ -41,16 +82,27 @@ class PublishDetails extends React.Component { + {isUpdate && ( + + + + + + )} + diff --git a/client/src/containers/PublishMetadataInputs/index.js b/client/src/containers/PublishMetadataInputs/index.js index b00b6f09..1c5b462a 100644 --- a/client/src/containers/PublishMetadataInputs/index.js +++ b/client/src/containers/PublishMetadataInputs/index.js @@ -8,6 +8,7 @@ const mapStateToProps = ({ publish }) => { description : publish.metadata.description, license : publish.metadata.license, nsfw : publish.metadata.nsfw, + isUpdate : publish.isUpdate, }; }; diff --git a/client/src/containers/PublishMetadataInputs/view.jsx b/client/src/containers/PublishMetadataInputs/view.jsx index 10d8a793..d5bfb3d9 100644 --- a/client/src/containers/PublishMetadataInputs/view.jsx +++ b/client/src/containers/PublishMetadataInputs/view.jsx @@ -26,27 +26,30 @@ class PublishMetadataInputs extends React.Component { this.props.onMetadataChange(name, selectedOption); } render () { + const { showMetadataInputs, description, isUpdate, nsfw } = this.props; return (
- {this.props.showMetadataInputs && ( + {(showMetadataInputs || isUpdate) && (
)} - + {!isUpdate && ( + + )}
); } diff --git a/client/src/containers/PublishStatus/view.jsx b/client/src/containers/PublishStatus/view.jsx index 71c04e7e..73212fba 100644 --- a/client/src/containers/PublishStatus/view.jsx +++ b/client/src/containers/PublishStatus/view.jsx @@ -12,7 +12,7 @@ class PublishStatus extends React.Component { {status === publishStates.LOAD_START &&
-

le is loading to server

+

File is loading to server

0%

@@ -42,7 +42,7 @@ class PublishStatus extends React.Component {
} - {status === publishStates.SUCCESS && + {status === publishStates.SUCCEEDED &&

Your publish is complete! You are being redirected to it now.

@@ -71,6 +71,13 @@ class PublishStatus extends React.Component {
} + {status === publishStates.ABANDONING && +
+ +

Your claim is being abandoned.

+
+
+ }
); } diff --git a/client/src/containers/PublishTool/index.js b/client/src/containers/PublishTool/index.js index 9258d560..86a4b6b3 100644 --- a/client/src/containers/PublishTool/index.js +++ b/client/src/containers/PublishTool/index.js @@ -1,11 +1,22 @@ import {connect} from 'react-redux'; import View from './view'; +import {selectAsset} from "../../selectors/show"; +import {buildURI} from "../../utils/buildURI"; -const mapStateToProps = ({ publish }) => { +const mapStateToProps = props => { + const { show, publish } = props; + const asset = selectAsset(show); + let uri; + if (asset) { + uri = `lbry://${buildURI(asset)}`; + } return { disabled: publish.disabled, - file : publish.file, - status : publish.status.status, + file: publish.file, + status: publish.status.status, + isUpdate: publish.isUpdate, + hasChanged: publish.hasChanged, + uri, }; }; diff --git a/client/src/containers/PublishTool/view.jsx b/client/src/containers/PublishTool/view.jsx index b628911a..86e4c2b2 100644 --- a/client/src/containers/PublishTool/view.jsx +++ b/client/src/containers/PublishTool/view.jsx @@ -1,23 +1,34 @@ import React from 'react'; +import { withRouter, Prompt } from 'react-router'; import Dropzone from '@containers/Dropzone'; import PublishPreview from '@components/PublishPreview'; import PublishStatus from '@containers/PublishStatus'; import PublishDisabledMessage from '@containers/PublishDisabledMessage'; +import { SAVE } from '../../constants/confirmation_messages'; class PublishTool extends React.Component { render () { - if (this.props.disabled) { + const {disabled, file, isUpdate, hasChanged, uri, status, location: currentLocation} = this.props; + if (disabled) { return ( ); } else { - if (this.props.file) { - if (this.props.status) { + if (file || isUpdate) { + if (status) { return ( ); } else { - return ; + return ( + + location.pathname === currentLocation.pathname ? false : SAVE} + /> + + + ); } } return ; @@ -25,4 +36,4 @@ class PublishTool extends React.Component { } }; -export default PublishTool; +export default withRouter(PublishTool); diff --git a/client/src/pages/AboutPage/index.jsx b/client/src/pages/AboutPage/index.jsx index 887e17f9..621f27b7 100644 --- a/client/src/pages/AboutPage/index.jsx +++ b/client/src/pages/AboutPage/index.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { withRouter } from 'react-router'; import PageLayout from '@components/PageLayout'; import HorizontalSplit from '@components/HorizontalSplit'; import AboutSpeechOverview from '@components/AboutSpeechOverview'; @@ -20,4 +21,4 @@ class AboutPage extends React.Component { } } -export default AboutPage; +export default withRouter(AboutPage); diff --git a/client/src/pages/EditPage/index.js b/client/src/pages/EditPage/index.js new file mode 100644 index 00000000..d7ca7b25 --- /dev/null +++ b/client/src/pages/EditPage/index.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { setUpdateTrue, setHasChanged, updateMetadata, clearFile } from '../../actions/publish'; +import { onHandleShowPageUri } from '../../actions/show'; +import { selectAsset } from '../../selectors/show'; +import View from './view'; + +const mapStateToProps = (props) => { + const { show } = props; + return { + asset : selectAsset(show), + myChannel: props.channel.loggedInChannel.name, + isUpdate : props.publish.isUpdate, + }; +}; + +const mapDispatchToProps = { + updateMetadata, + onHandleShowPageUri, + setUpdateTrue, + setHasChanged, + clearFile, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(View); diff --git a/client/src/pages/EditPage/view.jsx b/client/src/pages/EditPage/view.jsx new file mode 100644 index 00000000..60b07900 --- /dev/null +++ b/client/src/pages/EditPage/view.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PageLayout from '@components/PageLayout'; +import { Redirect } from 'react-router-dom'; +import PublishTool from '@containers/PublishTool'; + +class EditPage extends React.Component { + componentDidMount () { + const {asset, match, onHandleShowPageUri, setUpdateTrue, setHasChanged, updateMetadata} = this.props; + onHandleShowPageUri(match.params); + setUpdateTrue(); + if (asset) { + ['title', 'description', 'license', 'nsfw'].forEach(meta => updateMetadata(meta, asset.claimData[meta])); + } + setHasChanged(false); + } + componentWillUnmount () { + this.props.clearFile(); + } + render () { + const { myChannel, asset } = this.props; + // redirect if user does not own this claim + if ( + !myChannel || ( + asset && + asset.claimsData && + asset.claimsData.channelName && + asset.claimsData.channelName !== myChannel + ) + ) { + return (); + } + return ( + + + + ); + } +}; + +export default EditPage; diff --git a/client/src/pages/HomePage/index.jsx b/client/src/pages/HomePage/index.js similarity index 75% rename from client/src/pages/HomePage/index.jsx rename to client/src/pages/HomePage/index.js index bdff3cab..4695ef62 100644 --- a/client/src/pages/HomePage/index.jsx +++ b/client/src/pages/HomePage/index.js @@ -1,17 +1,20 @@ import { connect } from 'react-redux'; import { onHandleShowHomepage } from '../../actions/show'; +import { clearFile } from '../../actions/publish'; import View from './view'; -const mapStateToProps = ({ show, site, channel }) => { +const mapStateToProps = ({ show, site, channel, publish }) => { return { error : show.request.error, requestType: show.request.type, homeChannel: site.publishOnlyApproved && !channel.loggedInChannel.name ? `${site.approvedChannels[0].name}:${site.approvedChannels[0].longId}` : null, + isUpdate : publish.isUpdate, }; }; const mapDispatchToProps = { onHandleShowHomepage, + clearFile, }; export default connect(mapStateToProps, mapDispatchToProps)(View); diff --git a/client/src/pages/HomePage/view.jsx b/client/src/pages/HomePage/view.jsx index 491d00a1..36b8a78e 100644 --- a/client/src/pages/HomePage/view.jsx +++ b/client/src/pages/HomePage/view.jsx @@ -4,16 +4,9 @@ import PublishTool from '@containers/PublishTool'; import ContentPageWrapper from '@pages/ContentPageWrapper'; class HomePage extends React.Component { - componentDidMount () { - this.props.onHandleShowHomepage(this.props.match.params); + componentWillUnmount () { + this.props.clearFile(); } - - componentWillReceiveProps (nextProps) { - if (nextProps.match.params !== this.props.match.params) { - this.props.onHandleShowHomepage(nextProps.match.params); - } - } - render () { const { homeChannel } = this.props; return homeChannel ? ( diff --git a/client/src/pages/ShowAssetDetails/index.js b/client/src/pages/ShowAssetDetails/index.js index 0af0073c..cabb5046 100644 --- a/client/src/pages/ShowAssetDetails/index.js +++ b/client/src/pages/ShowAssetDetails/index.js @@ -1,20 +1,10 @@ import { connect } from 'react-redux'; +import { selectAsset } from '../../selectors/show'; import View from './view'; const mapStateToProps = ({ show }) => { - // select request info - const requestId = show.request.id; - // select asset info - let asset; - const request = show.requestList[requestId] || null; - const assetList = show.assetList; - if (request && assetList) { - const assetKey = request.key; // note: just store this in the request - asset = assetList[assetKey] || null; - }; - // return props return { - asset, + asset: selectAsset(show), }; }; diff --git a/client/src/pages/ShowAssetDetails/view.jsx b/client/src/pages/ShowAssetDetails/view.jsx index 98c09372..ed26c436 100644 --- a/client/src/pages/ShowAssetDetails/view.jsx +++ b/client/src/pages/ShowAssetDetails/view.jsx @@ -1,6 +1,5 @@ import React from 'react'; import PageLayout from '@components/PageLayout'; - import HorizontalSplit from '@components/HorizontalSplit'; import AssetTitle from '@containers/AssetTitle'; import AssetDisplay from '@containers/AssetDisplay'; diff --git a/client/src/reducers/publish.js b/client/src/reducers/publish.js index 29a31d7d..3282a722 100644 --- a/client/src/reducers/publish.js +++ b/client/src/reducers/publish.js @@ -41,6 +41,8 @@ const initialState = { license : '', nsfw : false, }, + isUpdate: false, + hasChanged: false, thumbnail: null, thumbnailChannel, thumbnailChannelId, @@ -49,8 +51,9 @@ const initialState = { export default function (state = initialState, action) { switch (action.type) { case actions.FILE_SELECTED: - return Object.assign({}, initialState, { // note: clears to initial state + return Object.assign({}, state.isUpdate ? state : initialState, { // note: clears to initial state file: action.data, + hasChanged: true, }); case actions.FILE_CLEAR: return initialState; @@ -59,14 +62,17 @@ export default function (state = initialState, action) { metadata: Object.assign({}, state.metadata, { [action.data.name]: action.data.value, }), + hasChanged: true, }); case actions.CLAIM_UPDATE: return Object.assign({}, state, { claim: action.data, + hasChanged: true, }); case actions.SET_PUBLISH_IN_CHANNEL: return Object.assign({}, state, { publishInChannel: action.channel, + hasChanged: true, }); case actions.PUBLISH_STATUS_UPDATE: return Object.assign({}, state, { @@ -83,13 +89,26 @@ export default function (state = initialState, action) { selectedChannel: action.data, }); case actions.TOGGLE_METADATA_INPUTS: - return Object.assign({}, state, { + return { + ...state, showMetadataInputs: action.data, - }); + }; case actions.THUMBNAIL_NEW: - return Object.assign({}, state, { + return { + ...state, thumbnail: action.data, - }); + hasChanged: true, + }; + case actions.SET_UPDATE_TRUE: + return { + ...state, + isUpdate: true, + }; + case actions.SET_HAS_CHANGED: + return { + ...state, + hasChanged: action.data, + }; default: return state; } diff --git a/client/src/reducers/show.js b/client/src/reducers/show.js index 91177370..a93da58b 100644 --- a/client/src/reducers/show.js +++ b/client/src/reducers/show.js @@ -65,6 +65,43 @@ export default function (state = initialState, action) { }, }), }); + case actions.ASSET_REMOVE: + const claim = action.data; + const newAssetList = state.assetList; + delete newAssetList[`a#${claim.name}#${claim.claimId}`]; + + const channelId = `c#${claim.channelName}#${claim.certificateId}`; + const channelClaims = state.channelList[channelId].claimsData.claims; + const newClaimsData = channelClaims.filter(c => c.claimId !== claim.claimId); + + return { + ...state, + assetList : newAssetList, + channelList: { + ...state.channelList, + [channelId]: { + ...state.channelList[channelId], + claimsData: { + ...state.channelList[channelId].claimsData, + claims: newClaimsData, + }, + }, + }, + }; + case actions.ASSET_UPDATE_CLAIMDATA: + return { + ...state, + assetList: { + ...state.assetList, + [action.data.id]: { + ...state.assetList[action.data.id], + claimData: { + ...state.assetList[action.data.id].claimData, + ...action.data.claimData, + }, + }, + }, + }; // channel data case actions.CHANNEL_ADD: return Object.assign({}, state, { @@ -77,7 +114,7 @@ export default function (state = initialState, action) { }, }), }); - case actions.CHANNEL_CLAIMS_UPDATE_SUCCESS: + case actions.CHANNEL_CLAIMS_UPDATE_SUCCEEDED: return Object.assign({}, state, { channelList: Object.assign({}, state.channelList, { [action.data.channelListId]: Object.assign({}, state.channelList[action.data.channelListId], { diff --git a/client/src/sagas/abandon.js b/client/src/sagas/abandon.js new file mode 100644 index 00000000..db290274 --- /dev/null +++ b/client/src/sagas/abandon.js @@ -0,0 +1,30 @@ +import { call, put, takeLatest } from 'redux-saga/effects'; +import * as actions from '../constants/publish_action_types'; +import * as publishStates from '../constants/publish_claim_states'; +import { updatePublishStatus, clearFile } from '../actions/publish'; +import { removeAsset } from '../actions/show'; +import { doAbandonClaim } from '../api/assetApi'; + +function * abandonClaim (action) { + const { claimData, history } = action.data; + const { claimId } = claimData; + + const confirm = window.confirm('Are you sure you want to abandon this claim? This action cannot be undone.'); + if (!confirm) return; + + yield put(updatePublishStatus(publishStates.ABANDONING, 'Your claim is being abandoned...')); + + try { + yield call(doAbandonClaim, claimId); + } catch (error) { + return console.log('abandon error:', error.message); + } + + yield put(clearFile()); + yield put(removeAsset(claimData)); + return history.push('/'); +} + +export function * watchAbandonClaim () { + yield takeLatest(actions.ABANDON_CLAIM, abandonClaim); +}; diff --git a/client/src/sagas/publish.js b/client/src/sagas/publish.js index 62157eca..7e61cda7 100644 --- a/client/src/sagas/publish.js +++ b/client/src/sagas/publish.js @@ -5,37 +5,57 @@ import { updateError, updatePublishStatus, clearFile } from '../actions/publish' import { selectPublishState } from '../selectors/publish'; import { selectChannelState } from '../selectors/channel'; import { selectSiteState } from '../selectors/site'; +import { selectShowState, selectAsset } from '../selectors/show'; import { validateChannelSelection, validateNoPublishErrors } from '../utils/validate'; import { createPublishMetadata, createPublishFormData, createThumbnailUrl } from '../utils/publish'; import { makePublishRequestChannel } from '../channels/publish'; function * publishFile (action) { const { history } = action.data; - const { publishInChannel, selectedChannel, file, claim, metadata, thumbnailChannel, thumbnailChannelId, thumbnail, error: publishToolErrors } = yield select(selectPublishState); + const publishState = yield select(selectPublishState); + const { publishInChannel, selectedChannel, file, claim, metadata, thumbnailChannel, thumbnailChannelId, thumbnail, isUpdate, error: publishToolErrors } = publishState; const { loggedInChannel } = yield select(selectChannelState); const { host } = yield select(selectSiteState); + + let show, asset; + if (isUpdate) { + show = yield select(selectShowState); + asset = selectAsset(show); + } // validate the channel selection try { validateChannelSelection(publishInChannel, selectedChannel, loggedInChannel); } catch (error) { return yield put(updateError('channel', error.message)); - }; + } // validate publish parameters try { validateNoPublishErrors(publishToolErrors); } catch (error) { return console.log('publish error:', error.message); } + + let publishMetadata, publishFormData, publishChannel; // create metadata - let publishMetadata = createPublishMetadata(claim, file, metadata, publishInChannel, selectedChannel); + publishMetadata = createPublishMetadata( + isUpdate ? asset.name : claim, + isUpdate ? {type: asset.claimData.contentType} : file, + metadata, + publishInChannel, + selectedChannel + ); + if (isUpdate) { + publishMetadata['channelName'] = asset.claimData.channelName; + } if (thumbnail) { // add thumbnail to publish metadata - publishMetadata['thumbnail'] = createThumbnailUrl(thumbnailChannel, thumbnailChannelId, claim, host); + publishMetadata['thumbnail'] = createThumbnailUrl(thumbnailChannel, thumbnailChannelId, claim, host); } // create form data for main publish - const publishFormData = createPublishFormData(file, thumbnail, publishMetadata); + publishFormData = createPublishFormData(file, thumbnail, publishMetadata); // make the publish request - const publishChannel = yield call(makePublishRequestChannel, publishFormData); + publishChannel = yield call(makePublishRequestChannel, publishFormData, isUpdate); + while (true) { const {loadStart, progress, load, success, error: publishError} = yield take(publishChannel); if (publishError) { @@ -43,7 +63,21 @@ function * publishFile (action) { } if (success) { yield put(clearFile()); - return history.push(`/${success.data.claimId}/${success.data.name}`); + if (isUpdate) { + yield put({ + type: 'ASSET_UPDATE_CLAIMDATA', + data: { + id : `a#${success.data.name}#${success.data.claimId}`, + claimData: success.data.claimData, + }, + }); + } + if (success.data.claimId) { + return history.push(success.data.pushTo); + } else { + // this returns to the homepage, needs work + return yield put(updatePublishStatus(publishStates.FAILED, 'ERROR')); + } } if (loadStart) { yield put(updatePublishStatus(publishStates.LOAD_START, null)); @@ -55,7 +89,7 @@ function * publishFile (action) { yield put(updatePublishStatus(publishStates.PUBLISHING, null)); } } -}; +} export function * watchPublishStart () { yield takeLatest(actions.PUBLISH_START, publishFile); diff --git a/client/src/sagas/rootSaga.js b/client/src/sagas/rootSaga.js index 05a5c471..39481bca 100644 --- a/client/src/sagas/rootSaga.js +++ b/client/src/sagas/rootSaga.js @@ -10,6 +10,7 @@ import { watchUpdateChannelAvailability } from './updateChannelAvailability'; import { watchChannelCreate } from './createChannel'; import { watchChannelLoginCheck } from './checkForLoggedInChannel'; import { watchChannelLogout } from './logoutChannel'; +import { watchAbandonClaim } from './abandon'; export function * rootSaga () { yield all([ @@ -27,5 +28,6 @@ export function * rootSaga () { watchChannelLoginCheck(), watchChannelLogout(), watchUpdateAssetViews(), + watchAbandonClaim(), ]); } diff --git a/client/src/selectors/show.js b/client/src/selectors/show.js index b3b5ba92..d7358e8e 100644 --- a/client/src/selectors/show.js +++ b/client/src/selectors/show.js @@ -1,7 +1,13 @@ -export const selectAsset = (show) => { - const request = show.requestList[show.request.id]; - const assetKey = request.key; - return show.assetList[assetKey]; +export const selectAsset = show => { + const requestId = show.request.id; + let asset; + const request = show.requestList[requestId] || null; + const assetList = show.assetList; + if (request && assetList) { + const assetKey = request.key; // note: just store this in the request + asset = assetList[assetKey] || null; + } + return asset; }; export const selectShowState = (state) => { diff --git a/client/src/utils/buildURI.js b/client/src/utils/buildURI.js new file mode 100644 index 00000000..b6fb599c --- /dev/null +++ b/client/src/utils/buildURI.js @@ -0,0 +1,10 @@ +export const buildURI = asset => { + let channelName, certificateId, name, claimId; + if (asset.claimData) { + ({ channelName, certificateId, name, claimId } = asset.claimData); + } + if (channelName) { + return `${channelName}:${certificateId}/${name}`; + } + return `${claimId}/${name}`; +}; diff --git a/client/src/utils/publish.js b/client/src/utils/publish.js index ecce371f..00ac2240 100644 --- a/client/src/utils/publish.js +++ b/client/src/utils/publish.js @@ -16,7 +16,9 @@ export const createPublishMetadata = (claim, { type }, { title, description, lic export const createPublishFormData = (file, thumbnail, metadata) => { let fd = new FormData(); // append file - fd.append('file', file); + if (file) { + fd.append('file', file); + } // append thumbnail if (thumbnail) { fd.append('thumbnail', thumbnail); @@ -31,5 +33,5 @@ export const createPublishFormData = (file, thumbnail, metadata) => { }; export const createThumbnailUrl = (channel, channelId, claim, host) => { - return `${host}/${channel}:${channelId}/${claim}-thumb.png`; + return `${host}/${channel}:${channelId}/${claim}-thumb.jpg`; }; diff --git a/server/chainquery/bundle.js b/server/chainquery/bundle.js index 2b51ddb8..e97ae49a 100644 --- a/server/chainquery/bundle.js +++ b/server/chainquery/bundle.js @@ -850,7 +850,7 @@ var claimQueries = (db, table, sequelize) => ({ }); }, - getShortClaimIdFromLongClaimId: async (claimId, claimName) => { + getShortClaimIdFromLongClaimId: async (claimId, claimName, pendingClaim) => { logger$1.debug(`claim.getShortClaimIdFromLongClaimId for ${claimName}#${claimId}`); return await table.findAll({ where: { name: claimName }, @@ -860,7 +860,12 @@ var claimQueries = (db, table, sequelize) => ({ throw new Error('No claim(s) found with that claim name'); } - return returnShortId(result, claimId); + let list = result.map(claim => claim.dataValues); + if (pendingClaim) { + list = list.concat(pendingClaim); + } + + return returnShortId(list, claimId); }); }, @@ -981,6 +986,24 @@ var claimQueries = (db, table, sequelize) => ({ }); }, + resolveClaimInChannel: async (claimName, channelId) => { + logger$1.debug(`Claim.resolveClaimByNames: ${claimName} in ${channelId}`); + return table.findAll({ + where: { + name: claimName, + publisher_id: channelId, + }, + }).then(claimArray => { + if (claimArray.length === 0) { + return null; + } else if (claimArray.length !== 1) { + logger$1.warn(`more than one record matches ${claimName} in ${channelId}`); + } + + return claimArray[0]; + }); + }, + getOutpoint: async (name, claimId) => { logger$1.debug(`finding outpoint for ${name}#${claimId}`); diff --git a/server/chainquery/queries/claimQueries.js b/server/chainquery/queries/claimQueries.js index d8deb21c..185959ae 100644 --- a/server/chainquery/queries/claimQueries.js +++ b/server/chainquery/queries/claimQueries.js @@ -49,7 +49,7 @@ export default (db, table, sequelize) => ({ }); }, - getShortClaimIdFromLongClaimId: async (claimId, claimName) => { + getShortClaimIdFromLongClaimId: async (claimId, claimName, pendingClaim) => { logger.debug(`claim.getShortClaimIdFromLongClaimId for ${claimName}#${claimId}`); return await table.findAll({ where: { name: claimName }, @@ -59,7 +59,12 @@ export default (db, table, sequelize) => ({ throw new Error('No claim(s) found with that claim name'); } - return returnShortId(result, claimId); + let list = result.map(claim => claim.dataValues); + if (pendingClaim) { + list = list.concat(pendingClaim); + } + + return returnShortId(list, claimId); }); }, @@ -180,6 +185,24 @@ export default (db, table, sequelize) => ({ }); }, + resolveClaimInChannel: async (claimName, channelId) => { + logger.debug(`Claim.resolveClaimByNames: ${claimName} in ${channelId}`); + return table.findAll({ + where: { + name: claimName, + publisher_id: channelId, + }, + }).then(claimArray => { + if (claimArray.length === 0) { + return null; + } else if (claimArray.length !== 1) { + logger.warn(`more than one record matches ${claimName} in ${channelId}`); + } + + return claimArray[0]; + }); + }, + getOutpoint: async (name, claimId) => { logger.debug(`finding outpoint for ${name}#${claimId}`); diff --git a/server/controllers/api/channel/claims/getChannelClaims.js b/server/controllers/api/channel/claims/getChannelClaims.js index d1ff3b96..90a9c2c7 100644 --- a/server/controllers/api/channel/claims/getChannelClaims.js +++ b/server/controllers/api/channel/claims/getChannelClaims.js @@ -5,7 +5,11 @@ const { returnPaginatedChannelClaims } = require('./channelPagination.js'); const getChannelClaims = async (channelName, channelShortId, page) => { const channelId = await chainquery.claim.queries.getLongClaimId(channelName, channelShortId); - const channelClaims = await chainquery.claim.queries.getAllChannelClaims(channelId); + + let channelClaims; + if (channelId) { + channelClaims = await chainquery.claim.queries.getAllChannelClaims(channelId); + } const processingChannelClaims = channelClaims ? channelClaims.map((claim) => getClaimData(claim)) : []; const processedChannelClaims = await Promise.all(processingChannelClaims); diff --git a/server/controllers/api/claim/abandon/index.js b/server/controllers/api/claim/abandon/index.js new file mode 100644 index 00000000..08a6505f --- /dev/null +++ b/server/controllers/api/claim/abandon/index.js @@ -0,0 +1,44 @@ +const logger = require('winston'); +const db = require('server/models'); +const { abandonClaim } = require('server/lbrynet'); +const deleteFile = require('../publish/deleteFile.js'); +const authenticateUser = require('../publish/authentication.js'); + +/* + route to abandon a claim through the daemon +*/ + +const claimAbandon = async (req, res) => { + const {claimId} = req.body; + const {user} = req; + try { + const [channel, claim] = await Promise.all([ + authenticateUser(user.channelName, null, null, user), + db.Claim.findOne({where: {claimId}}), + ]); + + if (!claim) throw new Error('That channel does not exist'); + if (!channel.channelName) throw new Error('You don\'t own this channel'); + + await abandonClaim({claimId}); + const file = await db.File.findOne({where: {claimId}}); + await Promise.all([ + deleteFile(file.filePath), + db.File.destroy({where: {claimId}}), + db.Claim.destroy({where: {claimId}}), + ]); + logger.debug(`Claim abandoned: ${claimId}`); + res.status(200).json({ + success: true, + message: `Claim with id ${claimId} abandonded`, + }); + } catch (error) { + logger.error('abandon claim error:', error); + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +module.exports = claimAbandon; diff --git a/server/controllers/api/claim/data/index.js b/server/controllers/api/claim/data/index.js index 6efe4e83..95146c37 100644 --- a/server/controllers/api/claim/data/index.js +++ b/server/controllers/api/claim/data/index.js @@ -1,8 +1,8 @@ const { handleErrorResponse } = require('../../../utils/errorHandlers.js'); const getClaimData = require('server/utils/getClaimData'); +const fetchClaimData = require('server/utils/fetchClaimData'); const chainquery = require('chainquery'); const db = require('server/models'); - /* route to return data for a claim @@ -10,16 +10,9 @@ const db = require('server/models'); */ const claimData = async ({ ip, originalUrl, body, params }, res) => { - const claimName = params.claimName; - let claimId = params.claimId; - if (claimId === 'none') claimId = null; try { - let resolvedClaim = await chainquery.claim.queries.resolveClaim(claimName, claimId).catch(() => {}); - - if(!resolvedClaim) { - resolvedClaim = await db.Claim.resolveClaim(claimName, claimId); - } + const resolvedClaim = await fetchClaimData(params); if (!resolvedClaim) { return res.status(404).json({ diff --git a/server/controllers/api/claim/publish/createPublishParams.js b/server/controllers/api/claim/publish/createPublishParams.js index 242f4312..364e831a 100644 --- a/server/controllers/api/claim/publish/createPublishParams.js +++ b/server/controllers/api/claim/publish/createPublishParams.js @@ -11,7 +11,7 @@ const createPublishParams = (filePath, name, title, description, license, nsfw, } // provide default for license if (license === null || license.trim() === '') { - license = ' '; // default to empty string + license = ''; // default to empty string } // create the basic publish params const publishParams = { diff --git a/server/controllers/api/claim/publish/index.js b/server/controllers/api/claim/publish/index.js index f51d4055..cee6c2bc 100644 --- a/server/controllers/api/claim/publish/index.js +++ b/server/controllers/api/claim/publish/index.js @@ -17,6 +17,9 @@ const parsePublishApiRequestBody = require('./parsePublishApiRequestBody.js'); const parsePublishApiRequestFiles = require('./parsePublishApiRequestFiles.js'); const authenticateUser = require('./authentication.js'); +const chainquery = require('chainquery'); +const createCanonicalLink = require('../../../../../utils/createCanonicalLink'); + const CLAIM_TAKEN = 'CLAIM_TAKEN'; const UNAPPROVED_CHANNEL = 'UNAPPROVED_CHANNEL'; @@ -42,7 +45,25 @@ const claimPublish = ({ body, files, headers, ip, originalUrl, user, tor }, res) }); } // define variables - let channelName, channelId, channelPassword, description, fileName, filePath, fileExtension, fileType, gaStartTime, license, name, nsfw, thumbnail, thumbnailFileName, thumbnailFilePath, thumbnailFileType, title; + let channelName, + channelId, + channelPassword, + description, + fileName, + filePath, + fileExtension, + fileType, + gaStartTime, + license, + name, + nsfw, + thumbnail, + thumbnailFileName, + thumbnailFilePath, + thumbnailFileType, + title, + claimData, + claimId; // record the start time of the request gaStartTime = Date.now(); // validate the body and files of the request @@ -64,6 +85,7 @@ const claimPublish = ({ body, files, headers, ip, originalUrl, user, tor }, res) }; throw error; } + return Promise.all([ checkClaimAvailability(name), createPublishParams(filePath, name, title, description, license, nsfw, thumbnail, channelName, channelClaimId), @@ -83,19 +105,40 @@ const claimPublish = ({ body, files, headers, ip, originalUrl, user, tor }, res) publish(thumbnailPublishParams, thumbnailFileName, thumbnailFileType); } // publish the asset - return publish(publishParams, fileName, fileType); + return publish(publishParams, fileName, fileType, filePath); }) - .then(result => { + .then(publishResults => { + logger.info('Publish success >', publishResults); + claimData = publishResults; + ({claimId} = claimData); + + if (channelName) { + return chainquery.claim.queries.getShortClaimIdFromLongClaimId(claimData.certificateId, channelName); + } else { + return chainquery.claim.queries.getShortClaimIdFromLongClaimId(claimId, name, claimData).catch(error => { + return claimId.slice(0, 1); + }); + } + }) + .then(shortId => { + let canonicalUrl; + if (channelName) { + canonicalUrl = createCanonicalLink({ asset: { ...claimData, channelShortId: shortId } }); + } else { + canonicalUrl = createCanonicalLink({ asset: { ...claimData, shortId } }) + } + res.status(200).json({ success: true, message: 'publish completed successfully', data : { name, - claimId : result.claim_id, - url : `${host}/${result.claim_id}/${name}`, // for backwards compatability with app - showUrl : `${host}/${result.claim_id}/${name}`, - serveUrl: `${host}/${result.claim_id}/${name}${fileExtension}`, - lbryTx : result, + claimId, + url : `${host}${canonicalUrl}`, // for backwards compatability with app + showUrl : `${host}${canonicalUrl}`, + serveUrl: `${host}${canonicalUrl}${fileExtension}`, + pushTo : canonicalUrl, + claimData, }, }); // record the publish end time and send to google analytics diff --git a/server/controllers/api/claim/publish/parsePublishApiRequestFiles.js b/server/controllers/api/claim/publish/parsePublishApiRequestFiles.js index 3e99ad20..6e1f3409 100644 --- a/server/controllers/api/claim/publish/parsePublishApiRequestFiles.js +++ b/server/controllers/api/claim/publish/parsePublishApiRequestFiles.js @@ -1,9 +1,19 @@ const path = require('path'); const validateFileTypeAndSize = require('./validateFileTypeAndSize.js'); -const parsePublishApiRequestFiles = ({file, thumbnail}) => { +const parsePublishApiRequestFiles = ({file, thumbnail}, isUpdate) => { // make sure a file was provided if (!file) { + if (isUpdate) { + if (thumbnail) { + const obj = {}; + obj.thumbnailFileName = thumbnail.name; + obj.thumbnailFilePath = thumbnail.path; + obj.thumbnailFileType = thumbnail.type; + return obj; + } + return {}; + } throw new Error('no file with key of [file] found in request'); } if (!file.path) { @@ -28,18 +38,24 @@ const parsePublishApiRequestFiles = ({file, thumbnail}) => { if (/'/.test(file.name)) { throw new Error('apostrophes are not allowed in the file name'); } + // validate the file - validateFileTypeAndSize(file); + if (file) validateFileTypeAndSize(file); // return results - return { - fileName : file.name, - filePath : file.path, - fileExtension : path.extname(file.path), - fileType : file.type, - thumbnailFileName: (thumbnail ? thumbnail.name : null), - thumbnailFilePath: (thumbnail ? thumbnail.path : null), - thumbnailFileType: (thumbnail ? thumbnail.type : null), + const obj = { + fileName : file.name, + filePath : file.path, + fileExtension: path.extname(file.path), + fileType : file.type, }; + + if (thumbnail) { + obj.thumbnailFileName = thumbnail.name; + obj.thumbnailFilePath = thumbnail.path; + obj.thumbnailFileType = thumbnail.type; + } + + return obj; }; module.exports = parsePublishApiRequestFiles; diff --git a/server/controllers/api/claim/publish/publish.js b/server/controllers/api/claim/publish/publish.js index 21de83bd..b0589ece 100644 --- a/server/controllers/api/claim/publish/publish.js +++ b/server/controllers/api/claim/publish/publish.js @@ -1,81 +1,72 @@ const logger = require('winston'); -const { publishClaim } = require('../../../../lbrynet'); const db = require('../../../../models'); +const { publishClaim } = require('../../../../lbrynet'); const { createFileRecordDataAfterPublish } = require('../../../../models/utils/createFileRecordData.js'); const { createClaimRecordDataAfterPublish } = require('../../../../models/utils/createClaimRecordData.js'); const deleteFile = require('./deleteFile.js'); -const publish = (publishParams, fileName, fileType) => { - return new Promise((resolve, reject) => { - let publishResults, certificateId, channelName; - // publish the file - return publishClaim(publishParams) - .then(result => { - logger.info(`Successfully published ${publishParams.name} ${fileName}`, result); +const publish = async (publishParams, fileName, fileType) => { + let publishResults; + let channel; + let fileRecord; + let newFile = Boolean(publishParams.file_path); - // Support new daemon, TODO: remove - publishResults = result.output && result.output.claim_id ? result.output : result; - - // get the channel information - if (publishParams.channel_name) { - logger.debug(`this claim was published in channel: ${publishParams.channel_name}`); - return db.Channel.findOne({ - where: { - channelName: publishParams.channel_name, - }, - }); - } else { - logger.debug('this claim was not published in a channel'); - return null; - } - }) - .then(channel => { - // set channel information - certificateId = null; - channelName = null; - if (channel) { - certificateId = channel.channelClaimId; - channelName = channel.channelName; - } - logger.debug(`certificateId: ${certificateId}`); - }) - .then(() => { - return Promise.all([ - createFileRecordDataAfterPublish(fileName, fileType, publishParams, publishResults), - createClaimRecordDataAfterPublish(certificateId, channelName, fileName, fileType, publishParams, publishResults), - ]); - }) - .then(([fileRecord, claimRecord]) => { - // upsert the records - const {name} = publishParams; - const {claim_id: claimId} = publishResults; - const upsertCriteria = { - name, - claimId, - }; - return Promise.all([ - db.upsert(db.File, fileRecord, upsertCriteria, 'File'), - db.upsert(db.Claim, claimRecord, upsertCriteria, 'Claim'), - ]); - }) - .then(([file, claim]) => { - logger.debug('File and Claim records successfully created'); - return Promise.all([ - file.setClaim(claim), - claim.setFile(file), - ]); - }) - .then(() => { - logger.debug('File and Claim records successfully associated'); - // resolve the promise with the result from lbryApi publishClaim; - resolve(publishResults); - }) - .catch(error => { - logger.error('PUBLISH ERROR', error); - deleteFile(publishParams.file_path); // delete the local file - reject(error); + try { + publishResults = await publishClaim(publishParams); + logger.info(`Successfully published ${publishParams.name} ${fileName}`, publishResults); + const outpoint = `${publishResults.output.txid}:${publishResults.output.nout}`; + // get the channel information + if (publishParams.channel_name) { + logger.debug(`this claim was published in channel: ${publishParams.channel_name}`); + channel = await db.Channel.findOne({ + where: { + channelName: publishParams.channel_name, + }, }); - }); + } else { + channel = null; + } + const certificateId = channel ? channel.channelClaimId : null; + const channelName = channel ? channel.channelName : null; + + const claimRecord = await createClaimRecordDataAfterPublish(certificateId, channelName, fileName, fileType, publishParams, publishResults); + const {claimId} = claimRecord; + const upsertCriteria = {name: publishParams.name, claimId}; + if (newFile) { + // this is the problem + // + fileRecord = await createFileRecordDataAfterPublish(fileName, fileType, publishParams, publishResults); + } else { + fileRecord = await db.File.findOne({where: {claimId}}).then(result => result.dataValues); + } + + const [file, claim] = await Promise.all([ + db.upsert(db.File, fileRecord, upsertCriteria, 'File'), + db.upsert(db.Claim, claimRecord, upsertCriteria, 'Claim'), + ]); + logger.info(`File and Claim records successfully created (${publishParams.name})`); + + await Promise.all([ + file.setClaim(claim), + claim.setFile(file), + ]); + logger.info(`File and Claim records successfully associated (${publishParams.name})`); + + return Object.assign({}, claimRecord, {outpoint}); + } catch (err) { + // parse daemon response when err is a string + // this needs work + logger.info('publish/publish err:', err); + const error = typeof err === 'string' ? JSON.parse(err) : err; + if (publishParams.file_path) { + await deleteFile(publishParams.file_path); + } + const message = error.error && error.error.message ? error.error.message : 'Unknown publish error'; + return { + error: true, + message, + }; + } }; module.exports = publish; diff --git a/server/controllers/api/claim/update/index.js b/server/controllers/api/claim/update/index.js new file mode 100644 index 00000000..c1df4fb8 --- /dev/null +++ b/server/controllers/api/claim/update/index.js @@ -0,0 +1,199 @@ +const logger = require('winston'); +const db = require('server/models'); +const { details, publishing: { disabled, disabledMessage, primaryClaimAddress } } = require('@config/siteConfig'); +const { resolveUri } = require('server/lbrynet'); +const { sendGATimingEvent } = require('../../../../utils/googleAnalytics.js'); +const { handleErrorResponse } = require('../../../utils/errorHandlers.js'); +const publish = require('../publish/publish.js'); +const parsePublishApiRequestBody = require('../publish/parsePublishApiRequestBody'); +const parsePublishApiRequestFiles = require('../publish/parsePublishApiRequestFiles.js'); +const authenticateUser = require('../publish/authentication.js'); +const createThumbnailPublishParams = require('../publish/createThumbnailPublishParams.js'); +const chainquery = require('chainquery'); +const createCanonicalLink = require('../../../../../utils/createCanonicalLink'); + +/* + route to update a claim through the daemon +*/ + +const updateMetadata = ({nsfw, license, title, description}) => { + const update = {}; + if (nsfw) update['nsfw'] = nsfw; + if (license) update['license'] = license; + if (title) update['title'] = title; + if (description) update['description'] = description; + return update; +}; + +const rando = () => { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 6; i += 1) text += possible.charAt(Math.floor(Math.random() * 62)); + return text; +}; + +const claimUpdate = ({ body, files, headers, ip, originalUrl, user, tor }, res) => { + // logging + logger.info('Claim update request:', { + ip, + headers, + body, + files, + user, + }); + + // check for disabled publishing + if (disabled) { + return res.status(503).json({ + success: false, + message: disabledMessage, + }); + } + + // define variables + let channelName, + channelId, + channelPassword, + description, + fileName, + filePath, + fileType, + gaStartTime, + thumbnail, + fileExtension, + license, + name, + nsfw, + thumbnailFileName, + thumbnailFilePath, + thumbnailFileType, + title, + claimRecord, + metadata, + publishResult, + thumbnailUpdate = false; + // record the start time of the request + gaStartTime = Date.now(); + + try { + ({name, nsfw, license, title, description, thumbnail} = parsePublishApiRequestBody(body)); + ({fileName, filePath, fileExtension, fileType, thumbnailFileName, thumbnailFilePath, thumbnailFileType} = parsePublishApiRequestFiles(files, true)); + ({channelName, channelId, channelPassword} = body); + } catch (error) { + return res.status(400).json({success: false, message: error.message}); + } + + // check channel authorization + authenticateUser(channelName, channelId, channelPassword, user) + .then(({ channelName, channelClaimId }) => { + if (!channelId) { + channelId = channelClaimId; + } + return chainquery.claim.queries.resolveClaimInChannel(name, channelClaimId).then(claim => claim.dataValues); + }) + .then(claim => { + claimRecord = claim; + if (claimRecord.content_type === 'video/mp4' && files.file) { + thumbnailUpdate = true; + } + + if (!files.file || thumbnailUpdate) { + return Promise.all([ + db.File.findOne({ where: { name, claimId: claim.claim_id } }), + resolveUri(`${claim.name}#${claim.claim_id}`), + ]); + } + + return [null, null]; + }) + .then(([fileResult, resolution]) => { + + metadata = Object.assign({}, { + title : claimRecord.title, + description: claimRecord.description, + nsfw : claimRecord.nsfw, + license : claimRecord.license, + language : 'en', + author : details.title, + }, updateMetadata({title, description, nsfw, license})); + const publishParams = { + name, + bid : '0.01', + claim_address: primaryClaimAddress, + channel_name : channelName, + channel_id : channelId, + metadata, + }; + + if (files.file) { + if (thumbnailUpdate) { + // publish new thumbnail + const newThumbnailName = `${name}-${rando()}`; + const newThumbnailParams = createThumbnailPublishParams(filePath, newThumbnailName, license, nsfw); + newThumbnailParams['file_path'] = filePath; + publish(newThumbnailParams, fileName, fileType); + + publishParams['sources'] = resolution.claim.value.stream.source; + publishParams['thumbnail'] = `${details.host}/${newThumbnailParams.channel_name}:${newThumbnailParams.channel_id}/${newThumbnailName}-thumb.jpg`; + } else { + publishParams['file_path'] = filePath; + } + } else { + fileName = fileResult.fileName; + fileType = fileResult.fileType; + publishParams['sources'] = resolution.claim.value.stream.source; + publishParams['thumbnail'] = claimRecord.thumbnail_url; + } + + const fp = files && files.file && files.file.path ? files.file.path : undefined; + return publish(publishParams, fileName, fileType, fp); + }) + .then(result => { + publishResult = result; + + if (channelName) { + return chainquery.claim.queries.getShortClaimIdFromLongClaimId(result.certificateId, channelName); + } else { + return chainquery.claim.queries.getShortClaimIdFromLongClaimId(result.claimId, name, result).catch(error => { + return result.claimId.slice(0, 1); + }); + } + }) + .then(shortId => { + let canonicalUrl; + if (channelName) { + canonicalUrl = createCanonicalLink({ asset: { ...publishResult, channelShortId: shortId } }); + } else { + canonicalUrl = createCanonicalLink({ asset: { ...publishResult, shortId } }) + } + + if (publishResult.error) { + res.status(400).json({ + success: false, + message: publishResult.message, + }); + } + + const {claimId} = publishResult; + res.status(200).json({ + success: true, + message: 'update successful', + data : { + name, + claimId, + url : `${details.host}${canonicalUrl}`, // for backwards compatability with app + showUrl : `${details.host}${canonicalUrl}`, + serveUrl: `${details.host}${canonicalUrl}${fileExtension}`, + pushTo : canonicalUrl, + claimData: publishResult, + }, + }); + // record the publish end time and send to google analytics + sendGATimingEvent('end-to-end', 'update', fileType, gaStartTime, Date.now()); + }) + .catch(error => { + handleErrorResponse(originalUrl, ip, error, res); + }); +}; + +module.exports = claimUpdate; diff --git a/server/lbrynet/index.js b/server/lbrynet/index.js index 1b914682..3b4eafcd 100644 --- a/server/lbrynet/index.js +++ b/server/lbrynet/index.js @@ -2,6 +2,7 @@ const axios = require('axios'); const logger = require('winston'); const { apiHost, apiPort, getTimeout } = require('@config/lbryConfig'); const lbrynetUri = 'http://' + apiHost + ':' + apiPort; +const db = require('../models'); const { chooseGaLbrynetPublishLabel, sendGATimingEvent } = require('../utils/googleAnalytics.js'); const handleLbrynetResponse = require('./utils/handleLbrynetResponse.js'); const { publishing } = require('@config/siteConfig'); @@ -46,6 +47,21 @@ module.exports = { }); }); }, + async abandonClaim ({claimId}) { + logger.debug(`lbryApi >> Abandon claim "${claimId}"`); + const gaStartTime = Date.now(); + try { + const abandon = await axios.post(lbrynetUri, { + method: 'claim_abandon', + params: { claim_id: claimId }, + }); + sendGATimingEvent('lbrynet', 'abandonClaim', 'ABANDON_CLAIM', gaStartTime, Date.now()); + return abandon.data; + } catch (error) { + logger.error(error); + return error; + } + }, getClaimList (claimName) { logger.debug(`lbryApi >> Getting claim_list for "${claimName}"`); const gaStartTime = Date.now(); @@ -75,7 +91,13 @@ module.exports = { }) .then(({ data }) => { sendGATimingEvent('lbrynet', 'resolveUri', 'RESOLVE', gaStartTime, Date.now()); - if (data.result[uri].error) { // check for errors + if (Object.keys(data.result).length === 0 && data.result.constructor === Object) { + // workaround for daemon returning empty result object + // https://github.com/lbryio/lbry/issues/1485 + db.Claim.findOne({ where: { claimId: uri.split('#')[1] } }) + .then(() => reject('This claim has not yet been confirmed on the LBRY blockchain')) + .catch(() => reject(`Claim ${uri} does not exist`)); + } else if (data.result[uri].error) { // check for errors reject(data.result[uri].error); } else { // if no errors, resolve resolve(data.result[uri]); diff --git a/server/models/utils/createFileRecordData.js b/server/models/utils/createFileRecordData.js index aa0802cf..fdcb9ce0 100644 --- a/server/models/utils/createFileRecordData.js +++ b/server/models/utils/createFileRecordData.js @@ -28,7 +28,7 @@ async function createFileRecordDataAfterGet (resolveResult, getResult) { filePath, fileType, }; -}; +} async function createFileRecordDataAfterPublish (fileName, fileType, publishParams, publishResults) { const { diff --git a/server/routes/api/index.js b/server/routes/api/index.js index 71b673eb..26706bd5 100644 --- a/server/routes/api/index.js +++ b/server/routes/api/index.js @@ -13,6 +13,8 @@ const claimGet = require('../../controllers/api/claim/get'); const claimList = require('../../controllers/api/claim/list'); const claimLongId = require('../../controllers/api/claim/longId'); const claimPublish = require('../../controllers/api/claim/publish'); +const claimAbandon = require('../../controllers/api/claim/abandon'); +const claimUpdate = require('../../controllers/api/claim/update'); const claimResolve = require('../../controllers/api/claim/resolve'); const claimShortId = require('../../controllers/api/claim/shortId'); const claimViews = require('../../controllers/api/claim/views'); @@ -29,12 +31,10 @@ const getOEmbedData = require('../../controllers/api/oEmbed'); module.exports = { // homepage routes '/api/homepage/data/channels': { controller: [ torCheckMiddleware, channelData ] }, - // channel routes '/api/channel/availability/:name': { controller: [ torCheckMiddleware, channelAvailability ] }, '/api/channel/short-id/:longId/:name': { controller: [ torCheckMiddleware, channelShortId ] }, '/api/channel/data/:channelName/:channelClaimId': { controller: [ torCheckMiddleware, channelData ] }, - '/api/channel/data/:channelName/:channelClaimId': { controller: [ torCheckMiddleware, channelData ] }, '/api/channel/claims/:channelName/:channelClaimId/:page': { controller: [ torCheckMiddleware, channelClaims ] }, // sepcial routes @@ -47,6 +47,8 @@ module.exports = { '/api/claim/list/:name': { controller: [ torCheckMiddleware, claimList ] }, '/api/claim/long-id': { method: 'post', controller: [ torCheckMiddleware, claimLongId ] }, // note: should be a 'get' '/api/claim/publish': { method: 'post', controller: [ torCheckMiddleware, autoblockPublishMiddleware, multipartMiddleware, autoblockPublishBodyMiddleware, claimPublish ] }, + '/api/claim/update': { method: 'post', controller: [ torCheckMiddleware, multipartMiddleware, claimUpdate ] }, + '/api/claim/abandon': { method: 'post', controller: [ torCheckMiddleware, multipartMiddleware, claimAbandon ] }, '/api/claim/resolve/:name/:claimId': { controller: [ torCheckMiddleware, claimResolve ] }, '/api/claim/short-id/:longId/:name': { controller: [ torCheckMiddleware, claimShortId ] }, '/api/claim/views/:claimId': { controller: [ torCheckMiddleware, claimViews ] }, diff --git a/server/routes/pages/index.js b/server/routes/pages/index.js index fc742b42..858a1898 100644 --- a/server/routes/pages/index.js +++ b/server/routes/pages/index.js @@ -15,6 +15,7 @@ module.exports = { '/trending': { controller: redirect('/popular') }, '/popular': { controller: handlePageRequest }, '/new': { controller: handlePageRequest }, + '/edit/:claimId': { controller: handlePageRequest }, '/multisite': { controller: handlePageRequest }, '/video-embed/:name/:claimId/:config?': { controller: handleVideoEmbedRequest }, // for twitter }; diff --git a/server/utils/fetchClaimData.js b/server/utils/fetchClaimData.js new file mode 100644 index 00000000..aeaec8f3 --- /dev/null +++ b/server/utils/fetchClaimData.js @@ -0,0 +1,25 @@ +const chainquery = require('chainquery'); +const db = require('server/models'); + +const fetchClaimData = async (params) => { + let { claimId, claimName: name } = params; + if (claimId === 'none') claimId = null; + + const [cq, local] = await Promise.all([ + chainquery.claim.queries.resolveClaim(name, claimId).then(res => res.dataValues).catch(() => {}), + db.Claim.resolveClaim(name, claimId).catch(() => {}), + ]); + + if (!cq && !local) { + return null; + } + if (cq && cq.name === name && !local) { + return cq; + } + if (local && local.name === name && !cq) { + return local; + } + return local.updatedAt > cq.modified_at ? local : cq; +}; + +module.exports = fetchClaimData; diff --git a/server/utils/getClaimData.js b/server/utils/getClaimData.js index f247a4d3..2e77a4ee 100644 --- a/server/utils/getClaimData.js +++ b/server/utils/getClaimData.js @@ -25,7 +25,7 @@ module.exports = async (data) => { claimId: data.claim_id || data.claimId, fileExt: data.generated_extension || data.fileExt, description: data.description, - thumbnail: data.generated_thumbnail || data.thumbnail, + thumbnail: data.generated_thumbnail || data.thumbnail_url || data.thumbnail, outpoint: data.transaction_hash_id || data.outpoint, host, })