From 4340a82a628bea08f7bdfa8bd23ecfa97c3fde1c Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 13 Sep 2024 16:26:08 +0200 Subject: [PATCH] Allow video uploads (#1399) * Allow video uploads * fix video preview --------- Co-authored-by: k00b --- api/paidAction/itemCreate.js | 6 +-- api/paidAction/itemUpdate.js | 4 +- api/resolvers/image.js | 25 ----------- api/resolvers/index.js | 3 +- api/resolvers/item.js | 2 +- api/resolvers/upload.js | 23 +++++++++- api/typeDefs/image.js | 16 ------- api/typeDefs/index.js | 3 +- api/typeDefs/upload.js | 18 +++++++- components/avatar.js | 7 +-- .../{image-upload.js => file-upload.js} | 33 +++++++++----- components/form.js | 42 +++++++++--------- components/media-or-link.js | 6 ++- lib/constants.js | 5 ++- middleware.js | 2 +- .../migration.sql | 43 +++++++++++++++++++ svgs/file-upload-line.svg | 1 + 17 files changed, 148 insertions(+), 91 deletions(-) delete mode 100644 api/resolvers/image.js delete mode 100644 api/typeDefs/image.js rename components/{image-upload.js => file-upload.js} (89%) create mode 100644 prisma/migrations/20240912235936_video_uploads/migration.sql create mode 100644 svgs/file-upload-line.svg diff --git a/api/paidAction/itemCreate.js b/api/paidAction/itemCreate.js index ee3cc126..40c0b0bb 100644 --- a/api/paidAction/itemCreate.js +++ b/api/paidAction/itemCreate.js @@ -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, diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index a0dfaf21..36335342 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -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)) } diff --git a/api/resolvers/image.js b/api/resolvers/image.js deleted file mode 100644 index caf2bd5b..00000000 --- a/api/resolvers/image.js +++ /dev/null @@ -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 } -} diff --git a/api/resolvers/index.js b/api/resolvers/index.js index 9d4e1ec7..afeae521 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -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] diff --git a/api/resolvers/item.js b/api/resolvers/item.js index c2649e0b..c22236bd 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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' diff --git a/api/resolvers/upload.js b/api/resolvers/upload.js index 9cf44332..80eab701 100644 --- a/api/resolvers/upload.js +++ b/api/resolvers/upload.js @@ -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 } +} diff --git a/api/typeDefs/image.js b/api/typeDefs/image.js deleted file mode 100644 index dc89947d..00000000 --- a/api/typeDefs/image.js +++ /dev/null @@ -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! - } -` diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index 8ca44aa6..29ed7dda 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -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] diff --git a/api/typeDefs/upload.js b/api/typeDefs/upload.js index 05a90e93..eca69678 100644 --- a/api/typeDefs/upload.js +++ b/api/typeDefs/upload.js @@ -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! + } ` diff --git a/components/avatar.js b/components/avatar.js index f3ad47ad..ad05378b 100644 --- a/components/avatar.js +++ b/components/avatar.js @@ -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 ( - { console.log(e) @@ -84,6 +85,6 @@ export default function Avatar ({ onSuccess }) { ? : } - + ) } diff --git a/components/image-upload.js b/components/file-upload.js similarity index 89% rename from components/image-upload.js rename to components/file-upload.js index a2bf1679..eaf04635 100644 --- a/components/image-upload.js +++ b/components/file-upload.js @@ -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 ( <> { 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) diff --git a/components/form.js b/components/form.js index 32ecf7e1..7e6b2d41 100644 --- a/components/form.js +++ b/components/form.js @@ -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 preview - 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) }} > - - + + 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 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]) diff --git a/lib/constants.js b/lib/constants.js index 3bc28d40..66cdbabe 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -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 diff --git a/middleware.js b/middleware.js index 307378ec..d99464c3 100644 --- a/middleware.js +++ b/middleware.js @@ -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 diff --git a/prisma/migrations/20240912235936_video_uploads/migration.sql b/prisma/migrations/20240912235936_video_uploads/migration.sql new file mode 100644 index 00000000..cd93e62e --- /dev/null +++ b/prisma/migrations/20240912235936_video_uploads/migration.sql @@ -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; +$$; diff --git a/svgs/file-upload-line.svg b/svgs/file-upload-line.svg new file mode 100644 index 00000000..53e5db53 --- /dev/null +++ b/svgs/file-upload-line.svg @@ -0,0 +1 @@ + \ No newline at end of file