Image uploads (#576)

* Add icon to add images

* Open file explorer to select image

* Upload images to S3 on selection

* Show uploaded images below text input

* Link and remove image

* Fetch unsubmitted images from database

* Mark S3 images as submitted in imgproxy job

* Add margin-top

* Mark images as submitted on client after successful mutation

* Also delete objects in S3

* Allow items to have multiple uploads linked

* Overwrite old avatar

* Add fees for presigned URLs

* Use Github style upload

* removed upfront fees
* removed images provider since we no longer need to keep track of unsubmitted images on the client
* removed User.images resolver
* removed deleteImage mutation
* use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload

* Add Upload.paid boolean column

One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation).

Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for.

This makes fee calculation easier since no JOINs are required.

* Add image fees during item creation/update

* we calculate image fees during item creation and update now
* function imageFees returns queries which deduct fees from user and mark images as paid + fees
* queries need to be run inside same transaction as item creation/update

* Allow anons to get presigned URLs

* Add comments regarding avatar upload

* Use megabytes in error message

* Remove unnecessary avatar check during image fees calculation

* Show image fees in frontend

* Also update image fees on blur

This makes sure that the images fees reflect the current state. For example, if an image was removed.

We could also add debounced requests.

* Show amount of unpaid images in receipt

* Fix fees in sats deducted from msats

* Fix algebraic order of fees

Spam fees must come immediately after the base fee since it multiplies the base fee.

* Fix image fees in edit receipt

* Fix stale fees shown

If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet.

* Add 0 base fee in edit receipt

* Remove 's' from 'image fees' in receipts

* Remove unnecessary async

* Remove 'Uploading <name>...' from text input on error

* Support upload of multiple files at once

* Add schedule to delete unused images

* Fix image fee display in receipts

* Use Drag and Drop API for image upload

* Remove dragOver style on drop

* Increase max upload size to 10MB to allow HQ camera pictures

* Fix free upload quota

* Fix stale image fees served

* Fix bad image fee return statements

* Fix multiplication with feesPerImage

* Fix NULL returned for size24h, sizeNow

* Remove unnecessary text field in query

* refactor: Unify <ImageUpload> and <Upload> component

* Add avatar cache busting using random query param

* Calculate image fee info in postgres function

* we now calculate image fee info in a postgres function which is much cleaner
* we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item!
* reversed changes in `serializeInvoiceable`

* Fix line break in receipt

* Update upload limits

* Add comment about `e.target.value = null`

* Use debounce instead of onBlur to update image fees info

* Fix invoice amount

* Refactor avatar upload control flow

* Update image fees in onChange

* Fix rescheduling of other jobs

* also update schedule from every minute to every hour

* Add image fees in calling context

* keep item ids on uploads

* Fix incompatible onSubmit signature

* Revert "keep item ids on uploads"

This reverts commit 4688962abc.

* many2many item uploads

* pretty subdomain for images

* handle upload conditions for profile images and job logos

---------

Co-authored-by: ekzyis <ek@ekzyis.com>
Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
ekzyis 2023-11-06 21:53:33 +01:00 committed by GitHub
parent 5fd74e9329
commit 8f590425dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 984 additions and 230 deletions

23
api/resolvers/image.js Normal file
View File

@ -0,0 +1,23 @@
import { ANON_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 null
return [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
}
export async function imageFeesInfo (s3Keys, { models, me }) {
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : ANON_USER_ID, s3Keys)
const imageFee = msatsToSats(info.imageFeeMsats)
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
const totalFees = msatsToSats(totalFeesMsats)
return { ...info, imageFee, totalFees, totalFeesMsats }
}

View File

@ -15,6 +15,7 @@ import price from './price'
import { GraphQLJSONObject as JSONObject } from 'graphql-type-json' import { GraphQLJSONObject as JSONObject } from 'graphql-type-json'
import admin from './admin' import admin from './admin'
import blockHeight from './blockHeight' import blockHeight from './blockHeight'
import image from './image'
import { GraphQLScalarType, Kind } from 'graphql' import { GraphQLScalarType, Kind } from 'graphql'
const date = new GraphQLScalarType({ const date = new GraphQLScalarType({
@ -45,4 +46,4 @@ const date = new GraphQLScalarType({
}) })
export default [user, item, message, wallet, lnurl, notifications, invite, sub, export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, search, growth, rewards, referrals, price, admin, blockHeight, { JSONObject }, { Date: date }] upload, search, growth, rewards, referrals, price, admin, blockHeight, image, { JSONObject }, { Date: date }]

View File

@ -19,6 +19,7 @@ import { sendUserNotification } from '../webPush'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item' import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications' import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
import { datePivot } from '../../lib/time' import { datePivot } from '../../lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image'
export async function commentFilterClause (me, models) { export async function commentFilterClause (me, models) {
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
@ -1090,10 +1091,13 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
item = { subName, userId: me.id, ...item } item = { subName, userId: me.id, ...item }
const fwdUsers = await getForwardUsers(models, forward) const fwdUsers = await getForwardUsers(models, forward)
const uploadIds = uploadIdsFromText(item.text, { models })
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
item = await serializeInvoicable( item = await serializeInvoicable(
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`, models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)), JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
{ models, lnd, hash, hmac, me } { models, lnd, hash, hmac, me, enforceFee: imgFees }
) )
await createMentions(item, models) await createMentions(item, models)
@ -1123,11 +1127,14 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
item.url = removeTracking(item.url) item.url = removeTracking(item.url)
} }
const enforceFee = me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0))) const uploadIds = uploadIdsFromText(item.text, { models })
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))) + imgFees
item = await serializeInvoicable( item = await serializeInvoicable(
models.$queryRawUnsafe( models.$queryRawUnsafe(
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`, `${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)), JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
{ models, lnd, hash, hmac, me, enforceFee } { models, lnd, hash, hmac, me, enforceFee }
) )

View File

@ -1,62 +1,42 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import AWS from 'aws-sdk' import { ANON_USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants' import { createPresignedPost } from '../s3'
const bucketRegion = 'us-east-1'
AWS.config.update({
region: bucketRegion
})
export default { export default {
Mutation: { Mutation: {
getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => { getSignedPOST: async (parent, { type, size, width, height, avatar }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in to get a signed url', { extensions: { code: 'FORBIDDEN' } })
}
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) { if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } })
} }
if (size > UPLOAD_SIZE_MAX) { if (size > UPLOAD_SIZE_MAX) {
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`, { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
}
if (avatar && size > UPLOAD_SIZE_MAX_AVATAR) {
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX_AVATAR / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
} }
if (width * height > IMAGE_PIXELS_MAX) { if (width * height > IMAGE_PIXELS_MAX) {
throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } })
} }
// create upload record const imgParams = {
const upload = await models.upload.create({ type,
data: { size,
type, width,
size, height,
width, userId: me?.id || ANON_USER_ID,
height, paid: false
userId: me.id }
}
})
// get presigned POST ur if (avatar) {
const s3 = new AWS.S3({ apiVersion: '2006-03-01' }) if (!me) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
const res = await new Promise((resolve, reject) => { imgParams.paid = undefined
s3.createPresignedPost({ }
Bucket: process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET,
Fields: {
key: String(upload.id)
},
Expires: 300,
Conditions: [
{ 'Content-Type': type },
{ 'Cache-Control': 'max-age=31536000' },
{ acl: 'public-read' },
['content-length-range', size, size]
]
}, (err, preSigned) => { if (err) { reject(err) } else { resolve(preSigned) } })
})
return res const upload = await models.upload.create({ data: { ...imgParams } })
return createPresignedPost({ key: String(upload.id), type, size })
} }
} }
} }

37
api/s3/index.js Normal file
View File

@ -0,0 +1,37 @@
import AWS from 'aws-sdk'
const bucketRegion = 'us-east-1'
const Bucket = process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET
AWS.config.update({
region: bucketRegion
})
export function createPresignedPost ({ key, type, size }) {
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })
return new Promise((resolve, reject) => {
s3.createPresignedPost({
Bucket,
Fields: { key },
Expires: 300,
Conditions: [
{ 'Content-Type': type },
{ 'Cache-Control': 'max-age=31536000' },
{ acl: 'public-read' },
['content-length-range', size, size]
]
}, (err, preSigned) => { err ? reject(err) : resolve(preSigned) })
})
}
export function deleteObjects (keys) {
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })
return new Promise((resolve, reject) => {
s3.deleteObjects({
Bucket,
Delete: {
Objects: keys.map(key => ({ Key: String(key) }))
}
}, (err, data) => { err ? reject(err) : resolve(keys) })
})
}

16
api/typeDefs/image.js Normal file
View File

@ -0,0 +1,16 @@
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

@ -16,6 +16,7 @@ import referrals from './referrals'
import price from './price' import price from './price'
import admin from './admin' import admin from './admin'
import blockHeight from './blockHeight' import blockHeight from './blockHeight'
import image from './image'
const common = gql` const common = gql`
type Query { type Query {
@ -35,4 +36,4 @@ const common = gql`
` `
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
sub, upload, growth, rewards, referrals, price, admin, blockHeight] sub, upload, growth, rewards, referrals, price, admin, blockHeight, image]

View File

@ -2,7 +2,7 @@ import { gql } from 'graphql-tag'
export default gql` export default gql`
extend type Mutation { extend type Mutation {
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!): SignedPost! getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
} }
type SignedPost { type SignedPost {

View File

@ -45,6 +45,18 @@ export default gql`
email: String email: String
} }
type Image {
id: ID!
createdAt: Date!
updatedAt: Date!
type: String!
size: Int!
width: Int
height: Int
itemId: Int
userId: Int!
}
type User { type User {
id: ID! id: ID!
createdAt: Date! createdAt: Date!

View File

@ -2,10 +2,10 @@ import { useRef, useState } from 'react'
import AvatarEditor from 'react-avatar-editor' import AvatarEditor from 'react-avatar-editor'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import BootstrapForm from 'react-bootstrap/Form' import BootstrapForm from 'react-bootstrap/Form'
import Upload from './upload'
import EditImage from '../svgs/image-edit-fill.svg' import EditImage from '../svgs/image-edit-fill.svg'
import Moon from '../svgs/moon-fill.svg' import Moon from '../svgs/moon-fill.svg'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { ImageUpload } from './image'
export default function Avatar ({ onSuccess }) { export default function Avatar ({ onSuccess }) {
const [uploading, setUploading] = useState() const [uploading, setUploading] = useState()
@ -49,27 +49,41 @@ export default function Avatar ({ onSuccess }) {
} }
return ( return (
<Upload <ImageUpload
as={({ onClick }) => avatar
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
{uploading
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>}
onError={e => { onError={e => {
console.log(e) console.log(e)
setUploading(false) setUploading(false)
}} }}
onSelect={(file, upload) => { onSelect={(file, upload) => {
showModal(onClose => <Body onClose={onClose} file={file} upload={upload} />) return new Promise((resolve, reject) =>
showModal(onClose => (
<Body
onClose={() => {
onClose()
resolve()
}}
file={file}
upload={async (blob) => {
await upload(blob)
resolve(blob)
}}
/>
)))
}} }}
onSuccess={async key => { onSuccess={({ id }) => {
onSuccess && onSuccess(key) onSuccess?.(id)
setUploading(false) setUploading(false)
}} }}
onStarted={() => { onUpload={() => {
setUploading(true) setUploading(true)
}} }}
/> >
<div className='position-absolute p-1 bg-dark pointer' style={{ bottom: '0', right: '0' }}>
{uploading
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>
</ImageUpload>
) )
} }

View File

@ -12,7 +12,7 @@ import AnonIcon from '../svgs/spy-fill.svg'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import Link from 'next/link' import Link from 'next/link'
function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { function Receipt ({ cost, repetition, imageFeesInfo, baseFee, parentId, boost }) {
return ( return (
<Table className={styles.receipt} borderless size='sm'> <Table className={styles.receipt} borderless size='sm'>
<tbody> <tbody>
@ -20,16 +20,16 @@ function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
<td>{numWithUnits(baseFee, { abbreviate: false })}</td> <td>{numWithUnits(baseFee, { abbreviate: false })}</td>
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td> <td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
</tr> </tr>
{hasImgLink &&
<tr>
<td>x 10</td>
<td align='right' className='font-weight-light'>image/link fee</td>
</tr>}
{repetition > 0 && {repetition > 0 &&
<tr> <tr>
<td>x 10<sup>{repetition}</sup></td> <td>x 10<sup>{repetition}</sup></td>
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td> <td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
</tr>} </tr>}
{imageFeesInfo.totalFees > 0 &&
<tr>
<td>+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}</td>
<td align='right' className='font-weight-light'>image fee</td>
</tr>}
{boost > 0 && {boost > 0 &&
<tr> <tr>
<td>+ {numWithUnits(boost, { abbreviate: false })}</td> <td>+ {numWithUnits(boost, { abbreviate: false })}</td>
@ -69,7 +69,7 @@ function AnonInfo () {
) )
} }
export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow, disabled }) { export default function FeeButton ({ parentId, baseFee, ChildButton, variant, text, alwaysShow, disabled }) {
const me = useMe() const me = useMe()
baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
const query = parentId const query = parentId
@ -79,46 +79,43 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton,
const repetition = me ? data?.itemRepetition || 0 : 0 const repetition = me ? data?.itemRepetition || 0 : 0
const formik = useFormikContext() const formik = useFormikContext()
const boost = Number(formik?.values?.boost) || 0 const boost = Number(formik?.values?.boost) || 0
const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost) const cost = baseFee * Math.pow(10, repetition) + Number(boost)
useEffect(() => { useEffect(() => {
formik?.setFieldValue('cost', cost) formik?.setFieldValue('cost', cost)
}, [formik?.getFieldProps('cost').value, cost]) }, [formik?.getFieldProps('cost').value, cost])
const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 }
const totalCost = cost + imageFeesInfo.totalFees
const show = alwaysShow || !formik?.isSubmitting const show = alwaysShow || !formik?.isSubmitting
return ( return (
<div className={styles.feeButton}> <div className={styles.feeButton}>
<ActionTooltip overlayText={numWithUnits(cost, { abbreviate: false })}> <ActionTooltip overlayText={numWithUnits(totalCost, { abbreviate: false })}>
<ChildButton variant={variant} disabled={disabled}>{text}{cost > 1 && show && <small> {numWithUnits(cost, { abbreviate: false })}</small>}</ChildButton> <ChildButton variant={variant} disabled={disabled}>{text}{totalCost > 1 && show && <small> {numWithUnits(totalCost, { abbreviate: false })}</small>}</ChildButton>
</ActionTooltip> </ActionTooltip>
{!me && <AnonInfo />} {!me && <AnonInfo />}
{cost > baseFee && show && {totalCost > baseFee && show &&
<Info> <Info>
<Receipt baseFee={baseFee} hasImgLink={hasImgLink} repetition={repetition} cost={cost} parentId={parentId} boost={boost} /> <Receipt baseFee={baseFee} imageFeesInfo={imageFeesInfo} repetition={repetition} cost={totalCost} parentId={parentId} boost={boost} />
</Info>} </Info>}
</div> </div>
) )
} }
function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) { function EditReceipt ({ cost, paidSats, imageFeesInfo, boost, parentId }) {
return ( return (
<Table className={styles.receipt} borderless size='sm'> <Table className={styles.receipt} borderless size='sm'>
<tbody> <tbody>
{addImgLink && <tr>
<> <td>{numWithUnits(0, { abbreviate: false })}</td>
<tr> <td align='right' className='font-weight-light'>edit fee</td>
<td>{numWithUnits(paidSats, { abbreviate: false })}</td> </tr>
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td> {imageFeesInfo.totalFees > 0 &&
</tr> <tr>
<tr> <td>+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}</td>
<td>x 10</td> <td align='right' className='font-weight-light'>image fee</td>
<td align='right' className='font-weight-light'>image/link fee</td> </tr>}
</tr>
<tr>
<td>- {numWithUnits(paidSats, { abbreviate: false })}</td>
<td align='right' className='font-weight-light'>already paid</td>
</tr>
</>}
{boost > 0 && {boost > 0 &&
<tr> <tr>
<td>+ {numWithUnits(boost, { abbreviate: false })}</td> <td>+ {numWithUnits(boost, { abbreviate: false })}</td>
@ -135,25 +132,27 @@ function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) {
) )
} }
export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) { export function EditFeeButton ({ paidSats, ChildButton, variant, text, alwaysShow, parentId }) {
const formik = useFormikContext() const formik = useFormikContext()
const boost = (formik?.values?.boost || 0) - (formik?.initialValues?.boost || 0) const boost = (formik?.values?.boost || 0) - (formik?.initialValues?.boost || 0)
const addImgLink = hasImgLink && !hadImgLink const cost = Number(boost)
const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost)
useEffect(() => { useEffect(() => {
formik?.setFieldValue('cost', cost) formik?.setFieldValue('cost', cost)
}, [formik?.getFieldProps('cost').value, cost]) }, [formik?.getFieldProps('cost').value, cost])
const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 }
const totalCost = cost + imageFeesInfo.totalFees
const show = alwaysShow || !formik?.isSubmitting const show = alwaysShow || !formik?.isSubmitting
return ( return (
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<ActionTooltip overlayText={numWithUnits(cost >= 0 ? cost : 0, { abbreviate: false })}> <ActionTooltip overlayText={numWithUnits(totalCost >= 0 ? totalCost : 0, { abbreviate: false })}>
<ChildButton variant={variant}>{text}{cost > 0 && show && <small> {numWithUnits(cost, { abbreviate: false })}</small>}</ChildButton> <ChildButton variant={variant}>{text}{totalCost > 0 && show && <small> {numWithUnits(totalCost, { abbreviate: false })}</small>}</ChildButton>
</ActionTooltip> </ActionTooltip>
{cost > 0 && show && {totalCost > 0 && show &&
<Info> <Info>
<EditReceipt paidSats={paidSats} addImgLink={addImgLink} cost={cost} parentId={parentId} boost={boost} /> <EditReceipt paidSats={paidSats} imageFeesInfo={imageFeesInfo} cost={totalCost} parentId={parentId} boost={boost} />
</Info>} </Info>}
</div> </div>
) )

View File

@ -1,6 +1,6 @@
.receipt { .receipt {
background-color: var(--theme-inputBg); background-color: var(--theme-inputBg);
max-width: 250px; max-width: 300px;
margin: auto; margin: auto;
table-layout: auto; table-layout: auto;
width: 100%; width: 100%;

View File

@ -9,12 +9,12 @@ import Dropdown from 'react-bootstrap/Dropdown'
import Nav from 'react-bootstrap/Nav' import Nav from 'react-bootstrap/Nav'
import Row from 'react-bootstrap/Row' import Row from 'react-bootstrap/Row'
import Markdown from '../svgs/markdown-line.svg' import Markdown from '../svgs/markdown-line.svg'
import AddImageIcon from '../svgs/image-add-line.svg'
import styles from './form.module.css' import styles from './form.module.css'
import Text from '../components/text' import Text from '../components/text'
import AddIcon from '../svgs/add-fill.svg' import AddIcon from '../svgs/add-fill.svg'
import { mdHas } from '../lib/md'
import CloseIcon from '../svgs/close-line.svg' import CloseIcon from '../svgs/close-line.svg'
import { useLazyQuery } from '@apollo/client' import { gql, useLazyQuery } from '@apollo/client'
import { TOP_USERS, USER_SEARCH } from '../fragments/users' import { TOP_USERS, USER_SEARCH } from '../fragments/users'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast' import { useToast } from './toast'
@ -24,6 +24,8 @@ import textAreaCaret from 'textarea-caret'
import ReactDatePicker from 'react-datepicker' import ReactDatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css' import 'react-datepicker/dist/react-datepicker.css'
import { debounce } from './use-debounce-callback' import { debounce } from './use-debounce-callback'
import { ImageUpload } from './image'
import { AWS_S3_URL_REGEXP } from '../lib/constants'
export function SubmitButton ({ export function SubmitButton ({
children, variant, value, onClick, disabled, cost, ...props children, variant, value, onClick, disabled, cost, ...props
@ -93,12 +95,34 @@ export function InputSkeleton ({ label, hint }) {
} }
const DEFAULT_MENTION_INDICES = { start: -1, end: -1 } const DEFAULT_MENTION_INDICES = { start: -1, end: -1 }
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) { export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
const [tab, setTab] = useState('write') const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props) const [, meta, helpers] = useField(props)
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 }) const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
innerRef = innerRef || useRef(null) innerRef = innerRef || useRef(null)
const imageUploadRef = useRef(null)
const previousTab = useRef(tab) const previousTab = useRef(tab)
const formik = useFormikContext()
const toaster = useToast()
const [updateImageFeesInfo] = useLazyQuery(gql`
query imageFeesInfo($s3Keys: [Int]!) {
imageFeesInfo(s3Keys: $s3Keys) {
totalFees
nUnpaid
imageFee
bytes24h
}
}`, {
fetchPolicy: 'no-cache',
nextFetchPolicy: 'no-cache',
onError: (err) => {
console.log(err)
toaster.danger(err.message || err.toString?.())
},
onCompleted: ({ imageFeesInfo }) => {
formik?.setFieldValue('imageFeesInfo', imageFeesInfo)
}
})
props.as ||= TextareaAutosize props.as ||= TextareaAutosize
props.rows ||= props.minRows || 6 props.rows ||= props.minRows || 6
@ -137,11 +161,14 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
innerRef.current.focus() innerRef.current.focus()
}, [mentionIndices, innerRef, helpers?.setValue]) }, [mentionIndices, innerRef, helpers?.setValue])
const imageFeesUpdate = useCallback(debounce(
(text) => {
const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : []
updateImageFeesInfo({ variables: { s3Keys } })
}, 1000), [debounce, updateImageFeesInfo])
const onChangeInner = useCallback((formik, e) => { const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e) if (onChange) onChange(formik, e)
if (setHasImgLink) {
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
}
// check for mention editing // check for mention editing
const { value, selectionStart } = e.target const { value, selectionStart } = e.target
let priorSpace = -1 let priorSpace = -1
@ -174,7 +201,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
setMentionQuery(undefined) setMentionQuery(undefined)
setMentionIndices(DEFAULT_MENTION_INDICES) setMentionIndices(DEFAULT_MENTION_INDICES)
} }
}, [onChange, setHasImgLink, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle])
imageFeesUpdate(value)
}, [onChange, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle, imageFeesUpdate])
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => { const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
return (e) => { return (e) => {
@ -209,6 +238,22 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
} }
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown]) }, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
const onDrop = useCallback((event) => {
event.preventDefault()
setDragStyle(null)
const changeEvent = new Event('change', { bubbles: true })
imageUploadRef.current.files = event.dataTransfer.files
imageUploadRef.current.dispatchEvent(changeEvent)
}, [imageUploadRef])
const [dragStyle, setDragStyle] = useState(null)
const onDragEnter = useCallback((e) => {
setDragStyle('over')
}, [setDragStyle])
const onDragLeave = useCallback((e) => {
setDragStyle(null)
}, [setDragStyle])
return ( return (
<FormGroup label={label} className={groupClassName}> <FormGroup label={label} className={groupClassName}>
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}> <div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
@ -219,12 +264,39 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
<Nav.Item> <Nav.Item>
<Nav.Link className={styles.previewTab} eventKey='preview' disabled={!meta.value}>preview</Nav.Link> <Nav.Link className={styles.previewTab} eventKey='preview' disabled={!meta.value}>preview</Nav.Link>
</Nav.Item> </Nav.Item>
<a <span className='ms-auto text-muted d-flex align-items-center'>
className='ms-auto text-muted d-flex align-items-center' <ImageUpload
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer' multiple
> ref={imageUploadRef}
<Markdown width={18} height={18} /> className='d-flex align-items-center me-1'
</a> onUpload={file => {
let text = innerRef.current.value
if (text) text += '\n\n'
text += `![Uploading ${file.name}…]()`
helpers.setValue(text)
}}
onSuccess={({ url, name }) => {
let text = innerRef.current.value
text = text.replace(`![Uploading ${name}…]()`, `![${name}](${url})`)
helpers.setValue(text)
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
updateImageFeesInfo({ variables: { s3Keys } })
}}
onError={({ name }) => {
let text = innerRef.current.value
text = text.replace(`![Uploading ${name}…]()`, '')
helpers.setValue(text)
}}
>
<AddImageIcon width={18} height={18} />
</ImageUpload>
<a
className='d-flex align-items-center'
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer'
>
<Markdown width={18} height={18} />
</a>
</span>
</Nav> </Nav>
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}> <div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
<UserSuggest <UserSuggest
@ -238,6 +310,10 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
onChange={onChangeInner} onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)} onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
onBlur={() => setTimeout(resetSuggestions, 100)} onBlur={() => setTimeout(resetSuggestions, 100)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>)} />)}
</UserSuggest> </UserSuggest>
</div> </div>
@ -675,6 +751,14 @@ export function Form ({
const onSubmitInner = useCallback(async (values, ...args) => { const onSubmitInner = useCallback(async (values, ...args) => {
try { try {
if (onSubmit) { if (onSubmit) {
// extract cost from formik fields
// (cost may also be set in a formik field named 'amount')
let cost = values?.cost || values?.amount
// add potential image fees which are set in a different field
// to differentiate between fees (in receipts for example)
cost += (values?.imageFeesInfo?.totalFees || 0)
values.cost = cost
const options = await onSubmit(values, ...args) const options = await onSubmit(values, ...args)
if (!storageKeyPrefix || options?.keepLocalStorage) return if (!storageKeyPrefix || options?.keepLocalStorage) return
clearLocalStorage(values) clearLocalStorage(values)

View File

@ -19,6 +19,10 @@
height: auto; height: auto;
} }
.dragOver {
box-shadow: 0 0 10px var(--bs-info);
}
.appendButton { .appendButton {
border-left: 0 !important; border-left: 0 !important;
border-top-left-radius: 0; border-top-left-radius: 0;

View File

@ -1,9 +1,13 @@
import styles from './text.module.css' import styles from './text.module.css'
import { useState, useEffect, useMemo, useCallback } from 'react' import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef } from 'react'
import { IMGPROXY_URL_REGEXP } from '../lib/url' import { IMGPROXY_URL_REGEXP } from '../lib/url'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useMe } from './me' import { useMe } from './me'
import { Dropdown } from 'react-bootstrap' import { Dropdown } from 'react-bootstrap'
import { UPLOAD_TYPES_ALLOW } from '../lib/constants'
import { useToast } from './toast'
import gql from 'graphql-tag'
import { useMutation } from '@apollo/client'
export function decodeOriginalUrl (imgproxyUrl) { export function decodeOriginalUrl (imgproxyUrl) {
const parts = imgproxyUrl.split('/') const parts = imgproxyUrl.split('/')
@ -132,3 +136,99 @@ export default function ZoomableImage ({ src, srcSet, ...props }) {
return <ImageOriginal src={originalUrl} onClick={handleClick} {...props} /> return <ImageOriginal src={originalUrl} onClick={handleClick} {...props} />
} }
export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => {
const toaster = useToast()
ref ??= useRef(null)
const [getSignedPOST] = useMutation(
gql`
mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!, $avatar: Boolean) {
getSignedPOST(type: $type, size: $size, width: $width, height: $height, avatar: $avatar) {
url
fields
}
}`)
const s3Upload = useCallback(file => {
const img = new window.Image()
img.src = window.URL.createObjectURL(file)
return new Promise((resolve, reject) => {
img.onload = async () => {
onUpload?.(file)
let data
const variables = {
avatar,
type: file.type,
size: file.size,
width: img.width,
height: img.height
}
try {
({ data } = await getSignedPOST({ variables }))
} catch (e) {
toaster.danger(e.message || e.toString?.())
onError?.({ ...variables, name: file.name, file })
reject(e)
return
}
const form = new FormData()
Object.keys(data.getSignedPOST.fields).forEach(key => form.append(key, data.getSignedPOST.fields[key]))
form.append('Content-Type', file.type)
form.append('Cache-Control', 'max-age=31536000')
form.append('acl', 'public-read')
form.append('file', file)
const res = await fetch(data.getSignedPOST.url, {
method: 'POST',
body: form
})
if (!res.ok) {
// TODO make sure this is actually a helpful error message and does not expose anything to the user we don't want
const err = res.statusText
toaster.danger(err)
onError?.({ ...variables, name: file.name, file })
reject(err)
return
}
const url = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${data.getSignedPOST.fields.key}`
// key is upload id in database
const id = data.getSignedPOST.fields.key
onSuccess?.({ ...variables, id, name: file.name, url, file })
resolve(id)
}
})
}, [toaster, getSignedPOST])
return (
<>
<input
ref={ref}
type='file'
multiple={multiple}
className='d-none'
accept={UPLOAD_TYPES_ALLOW.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(', ')}`)
continue
}
if (onSelect) await onSelect?.(file, s3Upload)
else await s3Upload(file)
// reset file input
// see https://bobbyhadz.com/blog/react-reset-file-input#reset-a-file-input-in-react
e.target.value = null
}
}}
/>
<div className={className} onClick={() => ref.current?.click()} style={{ cursor: 'pointer' }}>
{children}
</div>
</>
)
})

View File

@ -231,10 +231,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
// this function will be called before the Form's onSubmit handler is called // this function will be called before the Form's onSubmit handler is called
// and the form must include `cost` or `amount` as a value // and the form must include `cost` or `amount` as a value
const onSubmitWrapper = useCallback(async (formValues, ...submitArgs) => { const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => {
let { cost, amount } = formValues
cost ??= amount
// action only allowed if logged in // action only allowed if logged in
if (!me && options.requireSession) { if (!me && options.requireSession) {
throw new Error('you must be logged in') throw new Error('you must be logged in')

View File

@ -25,7 +25,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
<div className={styles.item}> <div className={styles.item}>
<Link href={`/items/${item.id}`}> <Link href={`/items/${item.id}`}>
<Image <Image
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage} src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
/> />
</Link> </Link>
<div className={`${styles.hunk} align-self-center mb-0`}> <div className={`${styles.hunk} align-self-center mb-0`}>

View File

@ -106,7 +106,7 @@ export default function JobForm ({ item, sub }) {
<label className='form-label'>logo</label> <label className='form-label'>logo</label>
<div className='position-relative' style={{ width: 'fit-content' }}> <div className='position-relative' style={{ width: 'fit-content' }}>
<Image <Image
src={logoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle src={logoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle
/> />
<Avatar onSuccess={setLogoId} /> <Avatar onSuccess={setLogoId} />
</div> </div>

View File

@ -1,90 +0,0 @@
import { useRef } from 'react'
import { gql, useMutation } from '@apollo/client'
import { UPLOAD_TYPES_ALLOW } from '../lib/constants'
export default function Upload ({ as: Component, onSelect, onStarted, onError, onSuccess }) {
const [getSignedPOST] = useMutation(
gql`
mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!) {
getSignedPOST(type: $type, size: $size, width: $width, height: $height) {
url
fields
}
}`)
const ref = useRef()
const upload = file => {
onStarted && onStarted()
const img = new window.Image()
img.src = window.URL.createObjectURL(file)
img.onload = async () => {
let data
try {
({ data } = await getSignedPOST({
variables: {
type: file.type,
size: file.size,
width: img.width,
height: img.height
}
}))
} catch (e) {
onError && onError(e.toString())
return
}
const form = new FormData()
Object.keys(data.getSignedPOST.fields).forEach(key =>
form.append(key, data.getSignedPOST.fields[key]))
form.append('Content-Type', file.type)
form.append('Cache-Control', 'max-age=31536000')
form.append('acl', 'public-read')
form.append('file', file)
const res = await fetch(data.getSignedPOST.url, {
method: 'POST',
body: form
})
if (!res.ok) {
onError && onError(res.statusText)
return
}
onSuccess && onSuccess(data.getSignedPOST.fields.key)
}
}
return (
<>
<input
ref={ref}
type='file'
className='d-none'
accept={UPLOAD_TYPES_ALLOW.join(', ')}
onChange={(e) => {
if (e.target.files.length === 0) {
return
}
const file = e.target.files[0]
if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) {
onError && onError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
return
}
if (onSelect) {
onSelect(file, upload)
} else {
upload(file)
}
e.target.value = null
}}
/>
<Component onClick={() => ref.current?.click()} />
</>
)
}

View File

@ -71,14 +71,20 @@ function HeaderPhoto ({ user, isMe }) {
} }
} }
}) })
},
onCompleted ({ setPhoto: photoId }) {
const src = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}`
setSrc(src)
} }
} }
) )
const initialSrc = user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'
const [src, setSrc] = useState(initialSrc)
return ( return (
<div className='position-relative align-self-start' style={{ width: 'fit-content' }}> <div className='position-relative align-self-start' style={{ width: 'fit-content' }}>
<Image <Image
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135' src={src} width='135' height='135'
className={styles.userimg} className={styles.userimg}
/> />
{isMe && {isMe &&

View File

@ -66,7 +66,7 @@ export default function UserList ({ ssrData, query, variables, destructureData }
<div className={`${styles.item} mb-2`} key={user.name}> <div className={`${styles.item} mb-2`} key={user.name}>
<Link href={`/${user.name}`}> <Link href={`/${user.name}`}>
<Image <Image
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='32' height='32' src={user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
className={`${userStyles.userimg} me-2`} className={`${userStyles.userimg} me-2`}
/> />
</Link> </Link>

View File

@ -6,8 +6,10 @@ export const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs')
export const NOFOLLOW_LIMIT = 1000 export const NOFOLLOW_LIMIT = 1000
export const BOOST_MULT = 5000 export const BOOST_MULT = 5000
export const BOOST_MIN = BOOST_MULT * 5 export const BOOST_MIN = BOOST_MULT * 5
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 export const UPLOAD_SIZE_MAX = 25 * 1024 * 1024
export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024
export const IMAGE_PIXELS_MAX = 35000000 export const IMAGE_PIXELS_MAX = 35000000
export const AWS_S3_URL_REGEXP = new RegExp(`https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/([0-9]+)`, 'g')
export const UPLOAD_TYPES_ALLOW = [ export const UPLOAD_TYPES_ALLOW = [
'image/gif', 'image/gif',
'image/heic', 'image/heic',

View File

@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the column `itemId` on the `Upload` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "Upload" DROP CONSTRAINT "Upload_itemId_fkey";
-- DropIndex
DROP INDEX "Upload.itemId_index";
-- DropIndex
DROP INDEX "Upload.itemId_unique";
-- AlterTable
ALTER TABLE "Upload" DROP COLUMN "itemId",
ADD COLUMN "paid" BOOLEAN;

View File

@ -0,0 +1,17 @@
-- add 'deleteUnusedImages' job
CREATE OR REPLACE FUNCTION create_delete_unused_images_job()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
INSERT INTO pgboss.schedule (name, cron, timezone) VALUES ('deleteUnusedImages', '0 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
return 0;
EXCEPTION WHEN OTHERS THEN
return 0;
END;
$$;
SELECT create_delete_unused_images_job();
DROP FUNCTION create_delete_unused_images_job;

View File

@ -0,0 +1,241 @@
-- function to calculate image fees info for given user and upload ids
CREATE OR REPLACE FUNCTION image_fees_info(user_id INTEGER, upload_ids INTEGER[])
RETURNS TABLE (
"bytes24h" INTEGER,
"bytesUnpaid" INTEGER,
"nUnpaid" INTEGER,
"imageFeeMsats" BIGINT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY SELECT
uploadinfo.*,
CASE
-- anons always pay 100 sats per image
WHEN user_id = 27 THEN 100000::BIGINT
ELSE CASE
-- 10 MB are free per stacker and 24 hours
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 10 * 1024 * 1024 THEN 0::BIGINT
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 25 * 1024 * 1024 THEN 10000::BIGINT
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 50 * 1024 * 1024 THEN 100000::BIGINT
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 100 * 1024 * 1024 THEN 1000000::BIGINT
ELSE 10000000::BIGINT
END
END AS "imageFeeMsats"
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;
$$;
-- add image fees
CREATE OR REPLACE FUNCTION create_item(
jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL, upload_ids INTEGER[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
cost_msats BIGINT;
freebie BOOLEAN;
item "Item";
med_votes FLOAT;
select_clause TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
-- access fields with appropriate types
item := jsonb_populate_record(NULL::"Item", jitem);
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
IF item."maxBid" IS NOT NULL THEN
cost_msats := 1000000;
ELSE
cost_msats := 1000 * POWER(10, item_spam(item."parentId", item."userId", spam_within));
END IF;
-- add image fees
IF upload_ids IS NOT NULL THEN
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
END IF;
-- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0
freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (item.boost IS NULL OR item.boost = 0);
IF NOT freebie AND cost_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- get this user's median item score
SELECT COALESCE(
percentile_cont(0.5) WITHIN GROUP(
ORDER BY "weightedVotes" - "weightedDownVotes"), 0)
INTO med_votes FROM "Item" WHERE "userId" = item."userId";
-- if their median votes are positive, start at 0
-- if the median votes are negative, start their post with that many down votes
-- basically: if their median post is bad, presume this post is too
-- addendum: if they're an anon poster, always start at 0
IF med_votes >= 0 OR item."userId" = 27 THEN
med_votes := 0;
ELSE
med_votes := ABS(med_votes);
END IF;
-- there's no great way to set default column values when using json_populate_record
-- so we need to only select fields with non-null values that way when func input
-- does not include a value, the default value is used instead of null
SELECT string_agg(quote_ident(key), ',') INTO select_clause
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key);
-- insert the item
EXECUTE format($fmt$
INSERT INTO "Item" (%s, "weightedDownVotes", freebie)
SELECT %1$s, %L, %L
FROM jsonb_populate_record(NULL::"Item", %L) RETURNING *
$fmt$, select_clause, med_votes, freebie, jitem) INTO item;
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
-- Automatically subscribe to one's own posts
INSERT INTO "ThreadSubscription" ("itemId", "userId")
VALUES (item.id, item."userId");
-- Automatically subscribe forward recipients to the new post
INSERT INTO "ThreadSubscription" ("itemId", "userId")
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
INSERT INTO "PollOption" ("itemId", "option")
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
IF NOT freebie THEN
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
VALUES (cost_msats, item.id, item."userId", 'FEE');
END IF;
-- if this item has boost
IF item.boost > 0 THEN
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
END IF;
-- if this is a job
IF item."maxBid" IS NOT NULL THEN
PERFORM run_auction(item.id);
END IF;
-- if this is a bio
IF item.bio THEN
UPDATE users SET "bioId" = item.id WHERE id = item."userId";
END IF;
-- schedule imgproxy job
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
RETURN item;
END;
$$;
-- add image fees
CREATE OR REPLACE FUNCTION update_item(
jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
cost_msats BIGINT;
item "Item";
select_clause TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := jsonb_populate_record(NULL::"Item", jitem);
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
cost_msats := 0;
-- add image fees
IF upload_ids IS NOT NULL THEN
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
END IF;
IF cost_msats > 0 AND cost_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
ELSE
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
VALUES (cost_msats, item.id, item."userId", 'FEE');
END IF;
IF item.boost > 0 THEN
UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id;
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
END IF;
IF item.status IS NOT NULL THEN
UPDATE "Item" SET "statusUpdatedAt" = now_utc()
WHERE id = item.id AND status <> item.status;
END IF;
SELECT string_agg(quote_ident(key), ',') INTO select_clause
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key)
WHERE key <> 'boost';
EXECUTE format($fmt$
UPDATE "Item" SET (%s) = (
SELECT %1$s
FROM jsonb_populate_record(NULL::"Item", %L)
) WHERE id = %L RETURNING *
$fmt$, select_clause, jitem, item.id) INTO item;
-- Delete any old thread subs if the user is no longer a fwd recipient
DELETE FROM "ThreadSubscription"
WHERE "itemId" = item.id
-- they aren't in the new forward list
AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId")
-- and they are in the old forward list
AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" );
-- Automatically subscribe any new forward recipients to the post
INSERT INTO "ThreadSubscription" ("itemId", "userId")
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward)
EXCEPT
SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id;
-- Delete all old forward entries, to recreate in next command
DELETE FROM "ItemForward" WHERE "itemId" = item.id;
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
INSERT INTO "PollOption" ("itemId", "option")
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
-- if this is a job
IF item."maxBid" IS NOT NULL THEN
PERFORM run_auction(item.id);
END IF;
-- schedule imgproxy job
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
RETURN item;
END;
$$;

View File

@ -0,0 +1,237 @@
-- CreateTable
CREATE TABLE "ItemUpload" (
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"itemId" INTEGER NOT NULL,
"uploadId" INTEGER NOT NULL,
CONSTRAINT "ItemUpload_pkey" PRIMARY KEY ("itemId","uploadId")
);
-- CreateIndex
CREATE INDEX "ItemUpload_created_at_idx" ON "ItemUpload"("created_at");
-- CreateIndex
CREATE INDEX "ItemUpload_itemId_idx" ON "ItemUpload"("itemId");
-- CreateIndex
CREATE INDEX "ItemUpload_uploadId_idx" ON "ItemUpload"("uploadId");
-- AddForeignKey
ALTER TABLE "ItemUpload" ADD CONSTRAINT "ItemUpload_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemUpload" ADD CONSTRAINT "ItemUpload_uploadId_fkey" FOREIGN KEY ("uploadId") REFERENCES "Upload"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- add image fees
CREATE OR REPLACE FUNCTION create_item(
jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL, upload_ids INTEGER[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
cost_msats BIGINT;
freebie BOOLEAN;
item "Item";
med_votes FLOAT;
select_clause TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
-- access fields with appropriate types
item := jsonb_populate_record(NULL::"Item", jitem);
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
IF item."maxBid" IS NOT NULL THEN
cost_msats := 1000000;
ELSE
cost_msats := 1000 * POWER(10, item_spam(item."parentId", item."userId", spam_within));
END IF;
-- add image fees
IF upload_ids IS NOT NULL THEN
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
END IF;
-- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0
freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (item.boost IS NULL OR item.boost = 0);
IF NOT freebie AND cost_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- get this user's median item score
SELECT COALESCE(
percentile_cont(0.5) WITHIN GROUP(
ORDER BY "weightedVotes" - "weightedDownVotes"), 0)
INTO med_votes FROM "Item" WHERE "userId" = item."userId";
-- if their median votes are positive, start at 0
-- if the median votes are negative, start their post with that many down votes
-- basically: if their median post is bad, presume this post is too
-- addendum: if they're an anon poster, always start at 0
IF med_votes >= 0 OR item."userId" = 27 THEN
med_votes := 0;
ELSE
med_votes := ABS(med_votes);
END IF;
-- there's no great way to set default column values when using json_populate_record
-- so we need to only select fields with non-null values that way when func input
-- does not include a value, the default value is used instead of null
SELECT string_agg(quote_ident(key), ',') INTO select_clause
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key);
-- insert the item
EXECUTE format($fmt$
INSERT INTO "Item" (%s, "weightedDownVotes", freebie)
SELECT %1$s, %L, %L
FROM jsonb_populate_record(NULL::"Item", %L) RETURNING *
$fmt$, select_clause, med_votes, freebie, jitem) INTO item;
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
-- Automatically subscribe to one's own posts
INSERT INTO "ThreadSubscription" ("itemId", "userId")
VALUES (item.id, item."userId");
-- Automatically subscribe forward recipients to the new post
INSERT INTO "ThreadSubscription" ("itemId", "userId")
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
INSERT INTO "PollOption" ("itemId", "option")
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
IF NOT freebie THEN
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
VALUES (cost_msats, item.id, item."userId", 'FEE');
END IF;
-- if this item has boost
IF item.boost > 0 THEN
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
END IF;
-- if this is a job
IF item."maxBid" IS NOT NULL THEN
PERFORM run_auction(item.id);
END IF;
-- if this is a bio
IF item.bio THEN
UPDATE users SET "bioId" = item.id WHERE id = item."userId";
END IF;
-- record attachments
IF upload_ids IS NOT NULL THEN
INSERT INTO "ItemUpload" ("itemId", "uploadId")
SELECT item.id, * FROM UNNEST(upload_ids);
END IF;
-- schedule imgproxy job
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
RETURN item;
END;
$$;
-- add image fees
CREATE OR REPLACE FUNCTION update_item(
jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats INTEGER;
cost_msats BIGINT;
item "Item";
select_clause TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
item := jsonb_populate_record(NULL::"Item", jitem);
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
cost_msats := 0;
-- add image fees
IF upload_ids IS NOT NULL THEN
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
-- delete any old uploads that are no longer attached
DELETE FROM "ItemUpload" WHERE "itemId" = item.id AND "uploadId" <> ANY(upload_ids);
-- insert any new uploads that are not already attached
INSERT INTO "ItemUpload" ("itemId", "uploadId")
SELECT item.id, * FROM UNNEST(upload_ids) ON CONFLICT DO NOTHING;
END IF;
IF cost_msats > 0 AND cost_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
ELSE
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
VALUES (cost_msats, item.id, item."userId", 'FEE');
END IF;
IF item.boost > 0 THEN
UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id;
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
END IF;
IF item.status IS NOT NULL THEN
UPDATE "Item" SET "statusUpdatedAt" = now_utc()
WHERE id = item.id AND status <> item.status;
END IF;
SELECT string_agg(quote_ident(key), ',') INTO select_clause
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key)
WHERE key <> 'boost';
EXECUTE format($fmt$
UPDATE "Item" SET (%s) = (
SELECT %1$s
FROM jsonb_populate_record(NULL::"Item", %L)
) WHERE id = %L RETURNING *
$fmt$, select_clause, jitem, item.id) INTO item;
-- Delete any old thread subs if the user is no longer a fwd recipient
DELETE FROM "ThreadSubscription"
WHERE "itemId" = item.id
-- they aren't in the new forward list
AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId")
-- and they are in the old forward list
AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" );
-- Automatically subscribe any new forward recipients to the post
INSERT INTO "ThreadSubscription" ("itemId", "userId")
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward)
EXCEPT
SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id;
-- Delete all old forward entries, to recreate in next command
DELETE FROM "ItemForward" WHERE "itemId" = item.id;
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
INSERT INTO "PollOption" ("itemId", "option")
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
-- if this is a job
IF item."maxBid" IS NOT NULL THEN
PERFORM run_auction(item.id);
END IF;
-- schedule imgproxy job
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
RETURN item;
END;
$$;

View File

@ -0,0 +1,5 @@
-- CreateIndex
CREATE INDEX "Item_uploadId_idx" ON "Item"("uploadId");
-- CreateIndex
CREATE INDEX "users_photoId_idx" ON "users"("photoId");

View File

@ -100,6 +100,7 @@ model User {
ArcOut Arc[] @relation("fromUser") ArcOut Arc[] @relation("fromUser")
ArcIn Arc[] @relation("toUser") ArcIn Arc[] @relation("toUser")
@@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@@index([inviteId], map: "users.inviteId_index") @@index([inviteId], map: "users.inviteId_index")
@@map("users") @@map("users")
@ -168,22 +169,35 @@ model Donation {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model Upload { model ItemUpload {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
type String itemId Int
size Int uploadId Int
width Int? item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
height Int? upload Upload @relation(fields: [uploadId], references: [id], onDelete: Cascade)
itemId Int? @unique(map: "Upload.itemId_unique")
userId Int @@id([itemId, uploadId])
item Item? @relation(fields: [itemId], references: [id]) @@index([createdAt])
user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade) @@index([itemId])
User User[] @@index([uploadId])
}
model Upload {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
type String
size Int
width Int?
height Int?
userId Int
paid Boolean?
user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade)
User User[]
ItemUpload ItemUpload[]
@@index([createdAt], map: "Upload.created_at_index") @@index([createdAt], map: "Upload.created_at_index")
@@index([itemId], map: "Upload.itemId_index")
@@index([userId], map: "Upload.userId_index") @@index([userId], map: "Upload.userId_index")
} }
@ -267,7 +281,6 @@ model Item {
company String? company String?
weightedVotes Float @default(0) weightedVotes Float @default(0)
boost Int @default(0) boost Int @default(0)
uploadId Int?
pollCost Int? pollCost Int?
paidImgLink Boolean @default(false) paidImgLink Boolean @default(false)
commentMsats BigInt @default(0) commentMsats BigInt @default(0)
@ -299,10 +312,12 @@ model Item {
PollOption PollOption[] PollOption PollOption[]
PollVote PollVote[] PollVote PollVote[]
ThreadSubscription ThreadSubscription[] ThreadSubscription ThreadSubscription[]
upload Upload?
User User[] User User[]
itemForwards ItemForward[] itemForwards ItemForward[]
ItemUpload ItemUpload[]
uploadId Int?
@@index([uploadId])
@@index([bio], map: "Item.bio_index") @@index([bio], map: "Item.bio_index")
@@index([createdAt], map: "Item.created_at_index") @@index([createdAt], map: "Item.created_at_index")
@@index([freebie], map: "Item.freebie_index") @@index([freebie], map: "Item.freebie_index")

View File

@ -0,0 +1,26 @@
import { deleteObjects } from '../api/s3'
export function deleteUnusedImages ({ models }) {
return async function ({ name }) {
console.log('running', name)
// delete all images in database and S3 which weren't paid in the last 24 hours
const unpaidImages = await models.$queryRaw`
SELECT id
FROM "Upload"
WHERE (paid = 'f'
OR (
-- for non-textarea images, they are free and paid is null
paid IS NULL
-- if the image is not used by a user or item (eg jobs), delete it
AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id)
AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id)
))
AND created_at < date_trunc('hour', now() - interval '24 hours')`
const s3Keys = unpaidImages.map(({ id }) => id)
console.log('deleting images:', s3Keys)
await deleteObjects(s3Keys)
await models.upload.deleteMany({ where: { id: { in: s3Keys } } })
}
}

View File

@ -64,10 +64,10 @@ export function imgproxy ({ models }) {
let imgproxyUrls = {} let imgproxyUrls = {}
try { try {
if (item.text) { if (item.text) {
imgproxyUrls = await createImgproxyUrls(id, item.text, { forceFetch }) imgproxyUrls = await createImgproxyUrls(id, item.text, { models, forceFetch })
} }
if (item.url && !isJob(item)) { if (item.url && !isJob(item)) {
imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { forceFetch })) } imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { models, forceFetch })) }
} }
} catch (err) { } catch (err) {
console.log('[imgproxy] error:', err) console.log('[imgproxy] error:', err)
@ -81,7 +81,7 @@ export function imgproxy ({ models }) {
} }
} }
export const createImgproxyUrls = async (id, text, { forceFetch }) => { export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
const urls = extractUrls(text) const urls = extractUrls(text)
console.log('[imgproxy] id:', id, '-- extracted urls:', urls) console.log('[imgproxy] id:', id, '-- extracted urls:', urls)
// resolutions that we target: // resolutions that we target:

View File

@ -16,6 +16,7 @@ import { authenticatedLndGrpc } from 'ln-service'
import { views, rankViews } from './views.js' import { views, rankViews } from './views.js'
import { imgproxy } from './imgproxy.js' import { imgproxy } from './imgproxy.js'
import { deleteItem } from './ephemeralItems.js' import { deleteItem } from './ephemeralItems.js'
import { deleteUnusedImages } from './deleteUnusedImages.js'
const { loadEnvConfig } = nextEnv const { loadEnvConfig } = nextEnv
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
@ -70,6 +71,7 @@ async function work () {
await boss.work('rankViews', rankViews(args)) await boss.work('rankViews', rankViews(args))
await boss.work('imgproxy', imgproxy(args)) await boss.work('imgproxy', imgproxy(args))
await boss.work('deleteItem', deleteItem(args)) await boss.work('deleteItem', deleteItem(args))
await boss.work('deleteUnusedImages', deleteUnusedImages(args))
console.log('working jobs') console.log('working jobs')
} }