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