Merge branch 'main' into feature/following-screen

This commit is contained in:
Stefano Sansone
2024-04-08 12:40:51 +02:00
16 changed files with 128 additions and 184 deletions

View File

@ -27,8 +27,8 @@ android {
applicationId = "app.omnivore.omnivore"
minSdk = 26
targetSdk = 34
versionCode = 194001
versionName = "0.195.0"
versionCode = 200004
versionName = "0.200.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -27,7 +27,7 @@ import app.omnivore.omnivore.core.database.entities.ViewerDao
HighlightChange::class,
SavedItemAndSavedItemLabelCrossRef::class,
SavedItemAndHighlightCrossRef::class],
version = 24,
version = 26,
exportSchema = true
)
abstract class OmnivoreDatabase : RoomDatabase() {

View File

@ -21,5 +21,7 @@ object DatabaseModule {
context,
OmnivoreDatabase::class.java,
"omnivore-database",
).build()
)
.fallbackToDestructiveMigration()
.build()
}

View File

@ -6,6 +6,7 @@ import android.content.Context
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@ -42,6 +43,7 @@ import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
fun notebookMD(notes: List<Highlight>, highlights: List<Highlight>): String {
@ -179,12 +181,15 @@ fun EditNoteModal(initialValue: String?, onDismiss: (save: Boolean, text: String
)
}
) { paddingValues ->
TextField(
modifier = Modifier
.padding(top = paddingValues.calculateTopPadding())
.focusRequester(focusRequester)
.fillMaxSize(),
keyboardOptions = KeyboardOptions.Default.copy(
autoCorrect = true,
capitalization = KeyboardCapitalization.Sentences
),
value = annotation.value, onValueChange = { annotation.value = it },
colors = TextFieldDefaults.textFieldColors(
focusedTextColor = MaterialTheme.colorScheme.onSurface,

View File

@ -0,0 +1,37 @@
import { env } from '../env'
import { sendWithMailJet } from '../services/send_emails'
import { Merge } from '../util'
import { logger } from '../utils/logger'
import { sendEmail } from '../utils/sendEmail'
export const SEND_EMAIL_JOB = 'send-email'
type ContentType = { html: string } | { text: string } | { templateId: string }
export type SendEmailJobData = Merge<
{
emailAddress: string
subject?: string
html?: string
text?: string
templateId?: string
dynamicTemplateData?: Record<string, any>
},
ContentType
>
export const sendEmailJob = async (data: SendEmailJobData) => {
if (process.env.USE_MAILJET && data.dynamicTemplateData) {
return sendWithMailJet(data.emailAddress, data.dynamicTemplateData.link)
}
if (!data.html && !data.text && !data.templateId) {
logger.error('no email content provided', data)
return false
}
return sendEmail({
...data,
from: env.sender.message,
to: data.emailAddress,
})
}

View File

@ -36,6 +36,7 @@ import {
import { refreshAllFeeds } from './jobs/rss/refreshAllFeeds'
import { refreshFeed } from './jobs/rss/refreshFeed'
import { savePageJob } from './jobs/save_page'
import { sendEmailJob, SEND_EMAIL_JOB } from './jobs/send_email'
import {
syncReadPositionsJob,
SYNC_READ_POSITIONS_JOB_NAME,
@ -157,6 +158,8 @@ export const createWorker = (connection: ConnectionOptions) =>
return processYouTubeTranscript(job.data)
case EXPORT_ALL_ITEMS_JOB_NAME:
return exportAllItems(job.data)
case SEND_EMAIL_JOB:
return sendEmailJob(job.data)
default:
logger.warning(`[queue-processor] unhandled job: ${job.name}`)
}

View File

@ -41,7 +41,7 @@ import {
} from '../../generated/graphql'
import { userRepository } from '../../repository/user'
import { createUser } from '../../services/create_user'
import { sendVerificationEmail } from '../../services/send_emails'
import { sendAccountChangeEmail } from '../../services/send_emails'
import { softDeleteUser } from '../../services/user'
import { userDataToUser } from '../../utils/helpers'
import { validateUsername } from '../../utils/usernamePolicy'
@ -358,7 +358,7 @@ export const updateEmailResolver = authorized<
return { email }
}
const result = await sendVerificationEmail({
const result = await sendAccountChangeEmail({
id: user.id,
name: user.name,
email,

View File

@ -23,7 +23,7 @@ import { userRepository } from '../../repository/user'
import { isErrorWithCode } from '../../resolvers'
import { createUser } from '../../services/create_user'
import {
sendConfirmationEmail,
sendNewAccountVerificationEmail,
sendPasswordResetEmail,
} from '../../services/send_emails'
import { analytics } from '../../utils/analytics'
@ -440,7 +440,7 @@ export function authRouter() {
}
if (user.status === StatusType.Pending && user.email) {
await sendConfirmationEmail({
await sendNewAccountVerificationEmail({
id: user.id,
email: user.email,
name: user.name,

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { StatusType } from '../../../entity/user'
import { userRepository } from '../../../repository/user'
import { sendConfirmationEmail } from '../../../services/send_emails'
import { sendNewAccountVerificationEmail } from '../../../services/send_emails'
import { comparePassword } from '../../../utils/auth'
import { logger } from '../../../utils/logger'
import { decodeAppleToken } from '../apple_auth'
@ -56,7 +56,7 @@ export async function createMobileEmailSignInResponse(
}
if (user.status === StatusType.Pending && user.email) {
await sendConfirmationEmail({
await sendNewAccountVerificationEmail({
id: user.id,
email: user.email,
name: user.name,

View File

@ -16,7 +16,7 @@ import { IntercomClient } from '../utils/intercom'
import { logger } from '../utils/logger'
import { validateUsername } from '../utils/usernamePolicy'
import { addPopularReadsForNewUser } from './popular_reads'
import { sendConfirmationEmail } from './send_emails'
import { sendNewAccountVerificationEmail } from './send_emails'
export const MAX_RECORDS_LIMIT = 1000
@ -142,7 +142,7 @@ export const createUser = async (input: {
})
if (input.pendingConfirmation) {
if (!(await sendConfirmationEmail(user))) {
if (!(await sendNewAccountVerificationEmail(user))) {
return Promise.reject({ errorCode: SignupErrorCode.InvalidEmail })
}
}

View File

@ -1,10 +1,10 @@
import mailjet from 'node-mailjet'
import { env } from '../env'
import { generateVerificationToken } from '../utils/auth'
import { enqueueSendEmail } from '../utils/createTask'
import { logger } from '../utils/logger'
import { sendEmail } from '../utils/sendEmail'
import mailjet from 'node-mailjet'
export const sendConfirmationEmail = async (user: {
export const sendNewAccountVerificationEmail = async (user: {
id: string
name: string
email: string
@ -14,23 +14,19 @@ export const sendConfirmationEmail = async (user: {
const link = `${env.client.url}/auth/confirm-email/${token}`
// send email
const dynamicTemplateData = {
name: user.name,
link,
}
if (process.env.USE_MAILJET) {
return sendWithMailJet(user.email, link)
}
return sendEmail({
from: env.sender.message,
to: user.email,
const result = await enqueueSendEmail({
emailAddress: user.email,
dynamicTemplateData: dynamicTemplateData,
templateId: env.sendgrid.confirmationTemplateId,
dynamicTemplateData,
})
return !!result
}
const sendWithMailJet = async (
export const sendWithMailJet = async (
email: string,
link: string
): Promise<boolean> => {
@ -68,7 +64,7 @@ const sendWithMailJet = async (
return true
}
export const sendVerificationEmail = async (user: {
export const sendAccountChangeEmail = async (user: {
id: string
name: string
email: string
@ -78,20 +74,16 @@ export const sendVerificationEmail = async (user: {
const link = `${env.client.url}/auth/reset-password/${token}`
// send email
const dynamicTemplateData = {
name: user.name,
link,
}
if (process.env.USE_MAILJET) {
return sendWithMailJet(user.email, link)
}
return sendEmail({
from: env.sender.message,
to: user.email,
const result = await enqueueSendEmail({
emailAddress: user.email,
dynamicTemplateData: dynamicTemplateData,
templateId: env.sendgrid.verificationTemplateId,
dynamicTemplateData,
})
return !!result
}
export const sendPasswordResetEmail = async (user: {
@ -104,18 +96,14 @@ export const sendPasswordResetEmail = async (user: {
const link = `${env.client.url}/auth/reset-password/${token}`
// send email
const dynamicTemplateData = {
name: user.name,
link,
}
if (process.env.USE_MAILJET) {
return sendWithMailJet(user.email, link)
}
return sendEmail({
from: env.sender.message,
to: user.email,
const result = await enqueueSendEmail({
emailAddress: user.email,
dynamicTemplateData: dynamicTemplateData,
templateId: env.sendgrid.resetPasswordTemplateId,
dynamicTemplateData,
})
return !!result
}

View File

@ -35,6 +35,7 @@ import {
REFRESH_ALL_FEEDS_JOB_NAME,
REFRESH_FEED_JOB_NAME,
} from '../jobs/rss/refreshAllFeeds'
import { SendEmailJobData, SEND_EMAIL_JOB } from '../jobs/send_email'
import { SYNC_READ_POSITIONS_JOB_NAME } from '../jobs/sync_read_positions'
import { TriggerRuleJobData, TRIGGER_RULE_JOB_NAME } from '../jobs/trigger_rule'
import {
@ -69,6 +70,7 @@ export const getJobPriority = (jobName: string): number => {
case UPDATE_LABELS_JOB:
case UPDATE_HIGHLIGHT_JOB:
case SYNC_READ_POSITIONS_JOB_NAME:
case SEND_EMAIL_JOB:
return 1
case TRIGGER_RULE_JOB_NAME:
case CALL_WEBHOOK_JOB_NAME:
@ -839,4 +841,16 @@ export const enqueueExportItem = async (jobData: ExportItemJobData) => {
})
}
export const enqueueSendEmail = async (jobData: SendEmailJobData) => {
const queue = await getBackendQueue()
if (!queue) {
return undefined
}
return queue.add(SEND_EMAIL_JOB, jobData, {
attempts: 1, // only try once
priority: getJobPriority(SEND_EMAIL_JOB),
})
}
export default createHttpTaskWithToken

View File

@ -1,11 +1,9 @@
import { MailDataRequired } from '@sendgrid/helpers/classes/mail'
import chai, { expect } from 'chai'
import sinon from 'sinon'
import sinonChai from 'sinon-chai'
import { expect } from 'chai'
import supertest from 'supertest'
import { StatusType, User } from '../../src/entity/user'
import { getRepository } from '../../src/repository'
import { userRepository } from '../../src/repository/user'
import { isValidSignupRequest } from '../../src/routers/auth/auth_router'
import { AuthProvider } from '../../src/routers/auth/auth_types'
import { createPendingUserToken } from '../../src/routers/auth/jwt_helpers'
import { searchLibraryItems } from '../../src/services/library_item'
@ -15,12 +13,8 @@ import {
generateVerificationToken,
hashPassword,
} from '../../src/utils/auth'
import * as util from '../../src/utils/sendEmail'
import { createTestUser } from '../db'
import { generateFakeUuid, request } from '../util'
import { isValidSignupRequest } from '../../src/routers/auth/auth_router'
chai.use(sinonChai)
describe('auth router', () => {
const route = '/api/auth'
@ -47,8 +41,6 @@ describe('auth router', () => {
let name: string
context('when inputs are valid and user not exists', () => {
let fake: (msg: MailDataRequired) => Promise<boolean>
before(() => {
password = validPassword
username = 'Some_username'
@ -62,14 +54,6 @@ describe('auth router', () => {
})
context('when confirmation email sent', () => {
beforeEach(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
})
afterEach(() => {
sinon.restore()
})
it('redirects to verify email', async () => {
const res = await signupRequest(
email,
@ -90,28 +74,6 @@ describe('auth router', () => {
expect(user?.name).to.eql(name)
})
})
context('when confirmation email not sent', () => {
before(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(false))
})
after(() => {
sinon.restore()
})
it('redirects to sign up page with error code INVALID_EMAIL', async () => {
const res = await signupRequest(
email,
password,
name,
username
).expect(302)
expect(res.header.location).to.endWith(
'/email-signup?errorCodes=INVALID_EMAIL'
)
})
})
})
context('when user exists', () => {
@ -213,11 +175,8 @@ describe('auth router', () => {
})
})
context('when user is not confirmed', async () => {
let fake: (msg: MailDataRequired) => Promise<boolean>
context('when user is not confirmed', () => {
beforeEach(async () => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
await updateUser(user.id, { status: StatusType.Pending })
email = user.email
password = correctPassword
@ -225,7 +184,6 @@ describe('auth router', () => {
afterEach(async () => {
await updateUser(user.id, { status: StatusType.Active })
sinon.restore()
})
it('redirects with error code PendingVerification', async () => {
@ -234,11 +192,6 @@ describe('auth router', () => {
'/email-login?errorCodes=PENDING_VERIFICATION'
)
})
it('sends a verification email', async () => {
await loginRequest(email, password).expect(302)
expect(fake).to.have.been.calledOnce
})
})
context('when user not exists', () => {
@ -254,7 +207,7 @@ describe('auth router', () => {
})
})
context('when user has no password stored in db', async () => {
context('when user has no password stored in db', () => {
before(async () => {
await updateUser(user.id, { password: '' })
email = user.email
@ -297,12 +250,10 @@ describe('auth router', () => {
let token: string
before(async () => {
sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
user = await createTestUser('pendingUser', undefined, 'password', true)
})
after(async () => {
sinon.restore()
await deleteUser(user.id)
})
@ -395,47 +346,16 @@ describe('auth router', () => {
})
context('when email is verified', () => {
let fake: (msg: MailDataRequired) => Promise<boolean>
before(async () => {
await updateUser(user.id, { status: StatusType.Active })
})
context('when reset password email sent', () => {
before(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
})
after(() => {
sinon.restore()
})
it('redirects to forgot-password page with success message', async () => {
const res = await emailResetPasswordReq(email).expect(302)
expect(res.header.location).to.endWith('/auth/reset-sent')
})
})
context('when reset password email not sent', () => {
before(() => {
fake = sinon.replace(
util,
'sendEmail',
sinon.fake.resolves(false)
)
})
after(() => {
sinon.restore()
})
it('redirects to sign up page with error code INVALID_EMAIL', async () => {
const res = await emailResetPasswordReq(email).expect(302)
expect(res.header.location).to.endWith(
'/forgot-password?errorCodes=INVALID_EMAIL'
)
})
})
})
context('when email is not verified', () => {
@ -499,7 +419,7 @@ describe('auth router', () => {
})
context('when token is valid', () => {
before(async () => {
before(() => {
token = generateVerificationToken({ id: user.id })
})
@ -517,8 +437,8 @@ describe('auth router', () => {
const updatedUser = await getRepository(User).findOneBy({
id: user?.id,
})
expect(await comparePassword(password, updatedUser?.password!)).to.be
.true
const newPassword = updatedUser?.password || ''
expect(await comparePassword(password, newPassword)).to.be.true
})
})
@ -580,12 +500,12 @@ describe('auth router', () => {
}
context('when inputs are valid and user not exists', () => {
let name = 'test_user'
let username = 'test_user'
let sourceUserId = 'test_source_user_id'
let email = 'test_user@omnivore.app'
let bio = 'test_bio'
let provider: AuthProvider = 'EMAIL'
const name = 'test_user'
const username = 'test_user'
const sourceUserId = 'test_source_user_id'
const email = 'test_user@omnivore.app'
const bio = 'test_bio'
const provider: AuthProvider = 'EMAIL'
afterEach(async () => {
const user = await userRepository.findOneByOrFail({ name })
@ -641,7 +561,7 @@ describe('auth router', () => {
})
describe('isValidSignupRequest', () => {
it('returns true for normal looking requests', async () => {
it('returns true for normal looking requests', () => {
const result = isValidSignupRequest({
email: 'email@omnivore.app',
password: 'superDuperPassword',
@ -650,7 +570,7 @@ describe('isValidSignupRequest', () => {
})
expect(result).to.be.true
})
it('returns false for requests w/missing info', async () => {
it('returns false for requests w/missing info', () => {
let result = isValidSignupRequest({
password: 'superDuperPassword',
name: "The User's Name",
@ -680,8 +600,8 @@ describe('isValidSignupRequest', () => {
expect(result).to.be.false
})
it('returns false for requests w/malicious info', async () => {
let result = isValidSignupRequest({
it('returns false for requests w/malicious info', () => {
const result = isValidSignupRequest({
password: 'superDuperPassword',
name: "You've won a cake sign up here: https://foo.bar",
username: 'foouser',

View File

@ -1,22 +1,15 @@
import { MailDataRequired } from '@sendgrid/helpers/classes/mail'
import chai, { expect } from 'chai'
import 'mocha'
import sinon from 'sinon'
import sinonChai from 'sinon-chai'
import { expect } from 'chai'
import { Filter } from '../../src/entity/filter'
import { StatusType, User } from '../../src/entity/user'
import { authTrx, getRepository } from '../../src/repository'
import { findProfile } from '../../src/services/profile'
import { deleteUser } from '../../src/services/user'
import * as util from '../../src/utils/sendEmail'
import {
createTestUser,
createUserWithoutProfile,
deleteFiltersFromUser,
} from '../db'
chai.use(sinonChai)
describe('create user', () => {
context('creates a user through manual sign up', () => {
it('adds the default filters to the user', async () => {
@ -95,15 +88,9 @@ describe('create user', () => {
context('create a user with pending confirmation', () => {
const name = 'pendingUser'
let fake: (msg: MailDataRequired) => Promise<boolean>
context('when email sends successfully', () => {
beforeEach(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
})
afterEach(async () => {
sinon.restore()
const user = await getRepository(User).findOneBy({ name })
await deleteUser(user!.id)
})
@ -114,29 +101,6 @@ describe('create user', () => {
expect(user.status).to.eql(StatusType.Pending)
expect(user.name).to.eql(name)
})
it('sends an email to the user', async () => {
await createTestUser(name, undefined, undefined, true)
expect(fake).to.have.been.calledOnce
})
})
context('when failed to send email', () => {
before(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(false))
})
after(async () => {
sinon.restore()
const user = await getRepository(User).findOneBy({ name })
await deleteUser(user!.id)
})
it('rejects with error', async () => {
return expect(createTestUser(name, undefined, undefined, true)).to.be
.rejected
})
})
})
})

View File

@ -30,6 +30,7 @@ export type Subscription = {
updatedAt: string
lastFetchedAt?: string
mostRecentItemDate?: string
failedAt?: string
fetchContentType?: FetchContentType
}
@ -74,6 +75,7 @@ export function useGetSubscriptionsQuery(
lastFetchedAt
fetchContentType
mostRecentItemDate
failedAt
}
}
... on SubscriptionsError {

View File

@ -229,11 +229,20 @@ export default function Rss(): JSX.Element {
}}
>
<SpanBox>{`URL: ${subscription.url}`}</SpanBox>
<SpanBox>{`Last refreshed: ${
subscription.lastFetchedAt
? formattedDateTime(subscription.lastFetchedAt)
: 'Never'
}`}</SpanBox>
{/* show failed timestamp instead of last refreshed timestamp if the feed failed to refresh */}
{subscription.failedAt ? (
<SpanBox
css={{ color: 'red' }}
>{`Failed to refresh: ${formattedDateTime(
subscription.failedAt
)}`}</SpanBox>
) : (
<SpanBox>{`Last refreshed: ${
subscription.lastFetchedAt
? formattedDateTime(subscription.lastFetchedAt)
: 'Never'
}`}</SpanBox>
)}
<SpanBox>
{subscription.mostRecentItemDate &&
`Most recent item: ${formattedDateTime(