Allow video uploads (#1399)

* Allow video uploads

* fix video preview

---------

Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
ekzyis 2024-09-13 16:26:08 +02:00 committed by GitHub
parent b3d9eb0eba
commit 4340a82a62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 148 additions and 91 deletions

View File

@ -11,14 +11,14 @@ export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio },
const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + image fees + boost
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
const [{ cost }] = await models.$queryRaw`
SELECT ${baseCost}::INTEGER
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
* ${me ? 1 : 100}::INTEGER
+ (SELECT "nUnpaid" * "imageFeeMsats"
FROM image_fees_info(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ (SELECT "nUnpaid" * "uploadFeesMsats"
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost`
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,

View File

@ -1,5 +1,5 @@
import { USER_ID } from '@/lib/constants'
import { imageFeesInfo } from '../resolvers/image'
import { uploadFees } from '../resolvers/upload'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { notifyItemMention, notifyMention } from '@/lib/webPush'
import { satsToMsats } from '@/lib/format'
@ -12,7 +12,7 @@ export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) {
// the only reason updating items costs anything is when it has new uploads
// or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await imageFeesInfo(uploadIds, { models, me })
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
return BigInt(totalFeesMsats) + satsToMsats(boost - (old.boost || 0))
}

View File

@ -1,25 +0,0 @@
import { USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
export default {
Query: {
imageFeesInfo: async (parent, { s3Keys }, { models, me }) => {
return imageFeesInfo(s3Keys, { models, me })
}
}
}
export function uploadIdsFromText (text, { models }) {
if (!text) return []
return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
}
export async function imageFeesInfo (s3Keys, { models, me }) {
// returns info object in this format:
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt }
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
const imageFee = msatsToSats(info.imageFeeMsats)
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
const totalFees = msatsToSats(totalFeesMsats)
return { ...info, imageFee, totalFees, totalFeesMsats }
}

View File

@ -16,7 +16,6 @@ import { GraphQLJSONObject as JSONObject } from 'graphql-type-json'
import admin from './admin'
import blockHeight from './blockHeight'
import chainFee from './chainFee'
import image from './image'
import { GraphQLScalarType, Kind } from 'graphql'
import { createIntScalar } from 'graphql-scalar'
import paidAction from './paidAction'
@ -56,4 +55,4 @@ const limit = createIntScalar({
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
image, { JSONObject }, { Date: date }, { Limit: limit }, paidAction]
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]

View File

@ -15,7 +15,7 @@ import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
import { datePivot, whenRange } from '@/lib/time'
import { uploadIdsFromText } from './image'
import { uploadIdsFromText } from './upload'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import performPaidAction from '../paidAction'

View File

@ -1,8 +1,14 @@
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants'
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW, AWS_S3_URL_REGEXP } from '@/lib/constants'
import { createPresignedPost } from '@/api/s3'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { msatsToSats } from '@/lib/format'
export default {
Query: {
uploadFees: async (parent, { s3Keys }, { models, me }) => {
return uploadFees(s3Keys, { models, me })
}
},
Mutation: {
getSignedPOST: async (parent, { type, size, width, height, avatar }, { models, me }) => {
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
@ -40,3 +46,18 @@ export default {
}
}
}
export function uploadIdsFromText (text, { models }) {
if (!text) return []
return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
}
export async function uploadFees (s3Keys, { models, me }) {
// returns info object in this format:
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, uploadFeesMsats: BigInt }
const [info] = await models.$queryRawUnsafe('SELECT * FROM upload_fees($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
const uploadFees = msatsToSats(info.uploadFeesMsats)
const totalFeesMsats = info.nUnpaid * Number(info.uploadFeesMsats)
const totalFees = msatsToSats(totalFeesMsats)
return { ...info, uploadFees, totalFees, totalFeesMsats }
}

View File

@ -1,16 +0,0 @@
import { gql } from 'graphql-tag'
export default gql`
type ImageFeesInfo {
totalFees: Int!
totalFeesMsats: Int!
imageFee: Int!
imageFeeMsats: Int!
nUnpaid: Int!
bytesUnpaid: Int!
bytes24h: Int!
}
extend type Query {
imageFeesInfo(s3Keys: [Int]!): ImageFeesInfo!
}
`

View File

@ -17,7 +17,6 @@ import price from './price'
import admin from './admin'
import blockHeight from './blockHeight'
import chainFee from './chainFee'
import image from './image'
import paidAction from './paidAction'
const common = gql`
@ -39,4 +38,4 @@ const common = gql`
`
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, image, paidAction]
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction]

View File

@ -1,12 +1,26 @@
import { gql } from 'graphql-tag'
export default gql`
extend type Mutation {
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
type UploadFees {
totalFees: Int!
totalFeesMsats: Int!
uploadFees: Int!
uploadFeesMsats: Int!
nUnpaid: Int!
bytesUnpaid: Int!
bytes24h: Int!
}
type SignedPost {
url: String!
fields: JSONObject!
}
extend type Query {
uploadFees(s3Keys: [Int]!): UploadFees!
}
extend type Mutation {
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
}
`

View File

@ -5,7 +5,7 @@ import BootstrapForm from 'react-bootstrap/Form'
import EditImage from '@/svgs/image-edit-fill.svg'
import Moon from '@/svgs/moon-fill.svg'
import { useShowModal } from './modal'
import { ImageUpload } from './image-upload'
import { FileUpload } from './file-upload'
export default function Avatar ({ onSuccess }) {
const [uploading, setUploading] = useState()
@ -49,7 +49,8 @@ export default function Avatar ({ onSuccess }) {
}
return (
<ImageUpload
<FileUpload
allow='image/*'
avatar
onError={e => {
console.log(e)
@ -84,6 +85,6 @@ export default function Avatar ({ onSuccess }) {
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>
</ImageUpload>
</FileUpload>
)
}

View File

@ -5,7 +5,7 @@ import gql from 'graphql-tag'
import { useMutation } from '@apollo/client'
import piexif from 'piexifjs'
export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => {
export const FileUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar, allow }, ref) => {
const toaster = useToast()
ref ??= useRef(null)
@ -19,18 +19,22 @@ export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload
}`)
const s3Upload = useCallback(async file => {
const img = new window.Image()
const element = file.type.startsWith('image/')
? new window.Image()
: document.createElement('video')
file = await removeExifData(file)
return new Promise((resolve, reject) => {
img.onload = async () => {
async function onload () {
onUpload?.(file)
let data
const variables = {
avatar,
type: file.type,
size: file.size,
width: img.width,
height: img.height
width: element.width,
height: element.height
}
try {
({ data } = await getSignedPOST({ variables }))
@ -66,13 +70,22 @@ export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload
// key is upload id in database
const id = data.getSignedPOST.fields.key
onSuccess?.({ ...variables, id, name: file.name, url, file })
console.log('resolve id', id)
resolve(id)
}
img.onerror = reject
img.src = window.URL.createObjectURL(file)
// img fire 'load' event while videos fire 'loadeddata'
element.onload = onload
element.onloadeddata = onload
element.onerror = reject
element.src = window.URL.createObjectURL(file)
})
}, [toaster, getSignedPOST])
const accept = UPLOAD_TYPES_ALLOW.filter(type => allow ? new RegExp(allow).test(type) : true)
return (
<>
<input
@ -80,12 +93,12 @@ export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload
type='file'
multiple={multiple}
className='d-none'
accept={UPLOAD_TYPES_ALLOW.join(', ')}
accept={accept.join(', ')}
onChange={async (e) => {
const fileList = e.target.files
for (const file of Array.from(fileList)) {
if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) {
toaster.danger(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
if (accept.indexOf(file.type) === -1) {
toaster.danger(`image must be ${accept.map(t => t.replace('image/', '').replace('video/', '')).join(', ')}`)
continue
}
if (onSelect) await onSelect?.(file, s3Upload)

View File

@ -9,7 +9,7 @@ import Dropdown from 'react-bootstrap/Dropdown'
import Nav from 'react-bootstrap/Nav'
import Row from 'react-bootstrap/Row'
import Markdown from '@/svgs/markdown-line.svg'
import AddImageIcon from '@/svgs/image-add-line.svg'
import AddFileIcon from '@/svgs/file-upload-line.svg'
import styles from './form.module.css'
import Text from '@/components/text'
import AddIcon from '@/svgs/add-fill.svg'
@ -23,7 +23,7 @@ import textAreaCaret from 'textarea-caret'
import ReactDatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import useDebounceCallback, { debounce } from './use-debounce-callback'
import { ImageUpload } from './image-upload'
import { FileUpload } from './file-upload'
import { AWS_S3_URL_REGEXP } from '@/lib/constants'
import { whenRange } from '@/lib/time'
import { useFeeButton } from './fee-button'
@ -122,12 +122,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const previousTab = useRef(tab)
const { merge, setDisabled: setSubmitDisabled } = useFeeButton()
const [updateImageFeesInfo] = useLazyQuery(gql`
query imageFeesInfo($s3Keys: [Int]!) {
imageFeesInfo(s3Keys: $s3Keys) {
const [updateUploadFees] = useLazyQuery(gql`
query uploadFees($s3Keys: [Int]!) {
uploadFees(s3Keys: $s3Keys) {
totalFees
nUnpaid
imageFee
uploadFees
bytes24h
}
}`, {
@ -136,13 +136,13 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
onError: (err) => {
console.error(err)
},
onCompleted: ({ imageFeesInfo }) => {
onCompleted: ({ uploadFees }) => {
merge({
imageFee: {
term: `+ ${numWithUnits(imageFeesInfo.totalFees, { abbreviate: false })}`,
label: 'image fee',
modifier: cost => cost + imageFeesInfo.totalFees,
omit: !imageFeesInfo.totalFees
uploadFees: {
term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
label: 'upload fee',
modifier: cost => cost + uploadFees.totalFees,
omit: !uploadFees.totalFees
}
})
}
@ -184,17 +184,17 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
innerRef.current.focus()
}, [mention, meta?.value, helpers?.setValue])
const imageFeesUpdate = useDebounceCallback(
const uploadFeesUpdate = useDebounceCallback(
(text) => {
const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : []
updateImageFeesInfo({ variables: { s3Keys } })
}, 1000, [updateImageFeesInfo])
updateUploadFees({ variables: { s3Keys } })
}, 1000, [updateUploadFees])
const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e)
// check for mention editing
const { value, selectionStart } = e.target
imageFeesUpdate(value)
uploadFeesUpdate(value)
if (!value || selectionStart === undefined) {
setMention(undefined)
@ -233,7 +233,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
} else {
setMention(undefined)
}
}, [onChange, setMention, imageFeesUpdate])
}, [onChange, setMention, uploadFeesUpdate])
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
return (e) => {
@ -321,7 +321,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
<Nav.Link className={styles.previewTab} eventKey='preview' disabled={!meta.value}>preview</Nav.Link>
</Nav.Item>
<span className='ms-auto text-muted d-flex align-items-center'>
<ImageUpload
<FileUpload
multiple
ref={imageUploadRef}
className='d-flex align-items-center me-1'
@ -344,7 +344,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
text = text.replace(`![Uploading ${name}…]()`, `![](${url})`)
helpers.setValue(text)
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
updateImageFeesInfo({ variables: { s3Keys } })
updateUploadFees({ variables: { s3Keys } })
setSubmitDisabled?.(false)
}}
onError={({ name }) => {
@ -354,8 +354,8 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
setSubmitDisabled?.(false)
}}
>
<AddImageIcon width={18} height={18} />
</ImageUpload>
<AddFileIcon width={18} height={18} />
</FileUpload>
<a
className='d-flex align-items-center'
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer'

View File

@ -112,7 +112,7 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
const { me } = useMe()
const trusted = useMemo(() => !!srcSetIntital || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetIntital, src])
const { dimensions, video, format, ...srcSetObj } = srcSetIntital || {}
const [isImage, setIsImage] = useState(!video && trusted)
const [isImage, setIsImage] = useState(video === false && trusted)
const [isVideo, setIsVideo] = useState(video)
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])
const embed = useMemo(() => parseEmbedUrl(src), [src])
@ -123,15 +123,19 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
// make sure it's not a false negative by trying to load URL as <img>
const img = new window.Image()
img.onload = () => setIsImage(true)
img.onerror = () => setIsImage(false)
img.src = src
const video = document.createElement('video')
video.onloadeddata = () => setIsVideo(true)
video.onerror = () => setIsVideo(false)
video.src = src
return () => {
img.onload = null
img.onerror = null
img.src = ''
video.onloadeddata = null
video.onerror = null
video.src = ''
}
}, [src, setIsImage, setIsVideo, showMedia, isVideo, embed])

View File

@ -19,7 +19,10 @@ export const UPLOAD_TYPES_ALLOW = [
'image/heic',
'image/png',
'image/jpeg',
'image/webp'
'image/webp',
'video/mp4',
'video/mpeg',
'video/webm'
]
export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE']
export const BOUNTY_MIN = 1000

View File

@ -87,7 +87,7 @@ export function middleware (request) {
"font-src 'self' a.stacker.news",
// we want to load images from everywhere but we can limit to HTTPS at least
"img-src 'self' a.stacker.news m.stacker.news https: data: blob:" + devSrc,
"media-src 'self' a.stacker.news m.stacker.news https:" + devSrc,
"media-src 'self' a.stacker.news m.stacker.news https: blob:" + devSrc,
// Using nonces and strict-dynamic deploys a strict CSP.
// see https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html#strict-policy.
// Old browsers will ignore nonce and strict-dynamic and fallback to host-based matching and unsafe-inline

View File

@ -0,0 +1,43 @@
-- rename image_fees_info to upload_fees
-- also give stackers more free quota (50MB per 24 hours -> 250MB per 24 hours)
DROP FUNCTION image_fees_info(user_id INTEGER, upload_ids INTEGER[]);
CREATE OR REPLACE FUNCTION upload_fees(user_id INTEGER, upload_ids INTEGER[])
RETURNS TABLE (
"bytes24h" INTEGER,
"bytesUnpaid" INTEGER,
"nUnpaid" INTEGER,
"uploadFeesMsats" BIGINT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY SELECT
uploadinfo.*,
CASE
-- anons always pay 100 sats per upload no matter the size
WHEN user_id = 27 THEN 100000::BIGINT
ELSE CASE
-- 250MB are free per stacker and 24 hours
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 250 * 1024 * 1024 THEN 0::BIGINT
-- 250MB-500MB: 10 sats per upload
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 500 * 1024 * 1024 THEN 10000::BIGINT
-- 500MB-1GB: 100 sats per upload
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 1000 * 1024 * 1024 THEN 100000::BIGINT
-- 1GB+: 1k sats per upload
ELSE 1000000::BIGINT
END
END AS "uploadFeesMsats"
FROM (
SELECT
-- how much bytes did stacker upload in last 24 hours?
COALESCE(SUM(size) FILTER(WHERE paid = 't' AND created_at >= NOW() - interval '24 hours'), 0)::INTEGER AS "bytes24h",
-- how much unpaid bytes do they want to upload now?
COALESCE(SUM(size) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "bytesUnpaid",
-- how many unpaid images do they want to upload now?
COALESCE(COUNT(id) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "nUnpaid"
FROM "Upload"
WHERE "Upload"."userId" = user_id
) uploadinfo;
RETURN;
END;
$$;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM13 12V16H11V12H8L12 8L16 12H13Z"></path></svg>

After

Width:  |  Height:  |  Size: 298 B