Browse Source

feat: add link expiration

pull/368/head
poeti8 9 months ago
parent
commit
00fc1faed9
14 changed files with 204 additions and 37 deletions
  1. +49
    -6
      client/components/LinksTable.tsx
  2. +28
    -4
      client/components/Shortener.tsx
  3. +2
    -0
      client/consts/consts.ts
  4. +3
    -1
      client/store/links.ts
  5. +12
    -0
      docs/api/api.ts
  6. +4
    -3
      global.d.ts
  7. +18
    -18
      package-lock.json
  8. +6
    -0
      server/cron.ts
  9. +13
    -3
      server/handlers/links.ts
  10. +1
    -0
      server/handlers/types.d.ts
  11. +35
    -2
      server/handlers/validators.ts
  12. +14
    -0
      server/migrations/20200730203154_expire_in.ts
  13. +1
    -0
      server/models/link.ts
  14. +18
    -0
      server/queries/link.ts

+ 49
- 6
client/components/LinksTable.tsx View File

@ -8,6 +8,8 @@ import { ifProp } from "styled-tools";
import getConfig from "next/config";
import QRCode from "qrcode.react";
import Link from "next/link";
import differenceInMilliseconds from "date-fns/differenceInMilliseconds";
import ms from "ms";
import { removeProtocol, withComma, errorMessage } from "../utils";
import { useStoreActions, useStoreState } from "../store";
@ -112,7 +114,8 @@ interface BanForm {
interface EditForm {
target: string;
address: string;
description: string;
description?: string;
expire_in?: string;
}
const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
@ -124,7 +127,12 @@ const Row: FC = ({ index, link, setDeleteModal }) => {
{
target: link.target,
address: link.address,
description: link.description
description: link.description,
expire_in: link.expire_in
? ms(differenceInMilliseconds(new Date(link.expire_in), new Date()), {
long: true
})
: ""
},
{ withIds: true }
);
@ -189,9 +197,20 @@ const Row: FC = ({ index, link, setDeleteModal }) => {
)}
</Col>
</Td>
<Td {...createdFlex}>{`${formatDistanceToNow(
new Date(link.created_at)
)} ago`}</Td>
<Td {...createdFlex} flexDirection="column" alignItems="flex-start">
<Text>{formatDistanceToNow(new Date(link.created_at))} ago</Text>
{link.expire_in && (
<Text fontSize={[13, 14]} color="#888">
Expires in{" "}
{ms(
differenceInMilliseconds(new Date(link.expire_in), new Date()),
{
long: true
}
)}
</Text>
)}
</Td>
<Td {...shortLinkFlex} withFade>
{copied ? (
<Animation
@ -362,7 +381,7 @@ const Row: FC = ({ index, link, setDeleteModal }) => {
</Col>
</Flex>
<Flex alignItems="flex-start" width={1} mt={3}>
<Col alignItems="flex-start">
<Col alignItems="flex-start" mr={3}>
<Text
{...label("description")}
as="label"
@ -386,6 +405,30 @@ const Row: FC = ({ index, link, setDeleteModal }) => {
/>
</Flex>
</Col>
<Col alignItems="flex-start">
<Text
{...label("expire_in")}
as="label"
mb={2}
fontSize={[14, 15]}
bold
>
Expire in:
</Text>
<Flex as="form">
<TextInput
{...text("expire_in")}
placeholder="2 minutes/hours/days"
placeholderSize={[13, 14]}
fontSize={[14, 15]}
height={[40, 44]}
width={[1, 210, 240]}
pl={[3, 24]}
pr={[3, 24]}
required
/>
</Flex>
</Col>
</Flex>
<Button
color="blue"

+ 28
- 4
client/components/Shortener.tsx View File

@ -55,6 +55,7 @@ interface Form {
customurl?: string;
password?: string;
description?: string;
expire_in?: string;
showAdvanced?: boolean;
}
@ -256,7 +257,7 @@ const Shortener = () => {
mb={2}
bold
>
Domain
Domain:
</Text>
<Select
{...select("domain")}
@ -323,15 +324,38 @@ const Shortener = () => {
</Col>
</Flex>
<Flex mt={[3]} flexDirection={["column", "row"]}>
<Col width={1}>
<Col>
<Text
as="description"
as="label"
{...label("expire_in")}
fontSize={[14, 15]}
mb={2}
bold
>
Expire in:
</Text>
<TextInput
{...text("expire_in")}
placeholder="2 minutes/hours/days"
data-lpignore
pl={[3, 24]}
pr={[3, 24]}
placeholderSize={[13, 14]}
fontSize={[14, 15]}
height={[40, 44]}
width={[1, 210, 240]}
maxWidth="100%"
/>
</Col>
<Col width={2 / 3} ml={[0, 26]}>
<Text
as="label"
{...label("description")}
fontSize={[14, 15]}
mb={2}
bold
>
Description
Description:
</Text>
<TextInput
{...text("description")}

+ 2
- 0
client/consts/consts.ts View File

@ -47,6 +47,8 @@ export enum Colors {
TableHeadBg = "hsl(200, 12%, 95%)",
TableHeadBorder = "hsl(200, 14%, 94%)",
TableRowHover = "hsl(200, 14%, 98%)",
TableRowBanned = "hsl(0, 100%, 98%)",
TableRowBannedHower = "hsl(0, 100%, 96%)",
TableShadow = "hsla(200, 20%, 70%, 0.3)",
Text = "hsl(200, 35%, 25%)",
TrashIcon = "hsl(0, 100%, 69%)",

+ 3
- 1
client/store/links.ts View File

@ -16,6 +16,7 @@ export interface Link {
domain_id?: number;
password?: string;
description?: string;
expire_in?: string;
target: string;
updated_at: string;
user_id?: number;
@ -43,7 +44,8 @@ export interface EditLink {
id: string;
target: string;
address: string;
description: string;
description?: string;
expire_in?: string;
}
export interface LinksQuery {

+ 12
- 0
docs/api/api.ts View File

@ -524,6 +524,10 @@ export default {
description: {
type: "string"
},
expire_in: {
type: "string",
example: "2 minutes/hours/days"
},
password: {
type: "string"
},
@ -547,12 +551,20 @@ export default {
}
},
body_1: {
required: ["target", "address"],
properties: {
target: {
type: "string"
},
address: {
type: "string"
},
description: {
type: "string"
},
expire_in: {
type: "string",
example: "2 minutes/hours/days"
}
}
},

+ 4
- 3
global.d.ts View File

@ -69,14 +69,15 @@ interface IP {
}
interface Link {
id: number;
address: string;
banned: boolean;
banned_by_id?: number;
banned: boolean;
created_at: string;
description?: string;
domain_id?: number;
expire_in: string;
id: number;
password?: string;
description?: string;
target: string;
updated_at: string;
user_id?: number;

+ 18
- 18
package-lock.json View File

@ -5183,19 +5183,19 @@
},
"babel-plugin-syntax-async-functions": {
"version": "6.13.0",
"resolved": "http://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz",
"integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=",
"dev": true
},
"babel-plugin-syntax-exponentiation-operator": {
"version": "6.13.0",
"resolved": "http://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz",
"integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=",
"dev": true
},
"babel-plugin-syntax-jsx": {
"version": "6.18.0",
"resolved": "http://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
"integrity": "sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY="
},
"babel-plugin-syntax-trailing-function-commas": {
@ -5460,13 +5460,13 @@
"dependencies": {
"jsesc": {
"version": "0.5.0",
"resolved": "http://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
"integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=",
"dev": true
},
"regexpu-core": {
"version": "2.0.0",
"resolved": "http://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz",
"integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=",
"dev": true,
"requires": {
@ -5477,13 +5477,13 @@
},
"regjsgen": {
"version": "0.2.0",
"resolved": "http://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz",
"resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz",
"integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=",
"dev": true
},
"regjsparser": {
"version": "0.1.5",
"resolved": "http://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz",
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz",
"integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=",
"dev": true,
"requires": {
@ -5995,7 +5995,7 @@
},
"browserify-aes": {
"version": "1.2.0",
"resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
"resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
"integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
"requires": {
"buffer-xor": "^1.0.3",
@ -6029,7 +6029,7 @@
},
"browserify-rsa": {
"version": "4.0.1",
"resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
"resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
"integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
"requires": {
"bn.js": "^4.1.0",
@ -6906,7 +6906,7 @@
},
"create-hash": {
"version": "1.2.0",
"resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
"integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
"requires": {
"cipher-base": "^1.0.1",
@ -6918,7 +6918,7 @@
},
"create-hmac": {
"version": "1.1.7",
"resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
"resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
"integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
"requires": {
"cipher-base": "^1.0.3",
@ -7615,7 +7615,7 @@
},
"diffie-hellman": {
"version": "5.0.3",
"resolved": "http://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
"integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
"requires": {
"bn.js": "^4.1.0",
@ -9968,7 +9968,7 @@
},
"got": {
"version": "6.7.1",
"resolved": "http://registry.npmjs.org/got/-/got-6.7.1.tgz",
"resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz",
"integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=",
"dev": true,
"requires": {
@ -14040,7 +14040,7 @@
},
"passport-jwt": {
"version": "4.0.0",
"resolved": "http://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz",
"integrity": "sha512-BwC0n2GP/1hMVjR4QpnvqA61TxenUMlmfNjYNgK0ZAs0HK4SOQkHcSv4L328blNTLtHq7DbmvyNJiH+bn6C5Mg==",
"requires": {
"jsonwebtoken": "^8.2.0",
@ -15479,7 +15479,7 @@
},
"readable-stream": {
"version": "2.3.6",
"resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"requires": {
"core-util-is": "~1.0.0",
@ -16065,7 +16065,7 @@
},
"safe-regex": {
"version": "1.1.0",
"resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
"integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
"requires": {
"ret": "~0.1.10"
@ -16275,7 +16275,7 @@
},
"sha.js": {
"version": "2.4.11",
"resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
"requires": {
"inherits": "^2.0.1",
@ -16837,7 +16837,7 @@
},
"string_decoder": {
"version": "1.1.1",
"resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"

+ 6
- 0
server/cron.ts View File

@ -8,3 +8,9 @@ if (env.NON_USER_COOLDOWN) {
query.ip.clear().catch();
});
}
cron.schedule("*/15 * * * * *", () => {
query.link
.batchRemove({ expire_in: ["<", new Date().toISOString()] })
.catch();
});

+ 13
- 3
server/handlers/links.ts View File

@ -42,7 +42,15 @@ export const get: Handler = async (req, res) => {
};
export const create: Handler = async (req: CreateLinkReq, res) => {
const { reuse, password, customurl, description, target, domain } = req.body;
const {
reuse,
password,
customurl,
description,
target,
domain,
expire_in
} = req.body;
const domain_id = domain ? domain.id : null;
const targetDomain = URL.parse(target).hostname;
@ -87,6 +95,7 @@ export const create: Handler = async (req: CreateLinkReq, res) => {
domain_id,
description,
target,
expire_in,
user_id: req.user && req.user.id
});
@ -100,7 +109,7 @@ export const create: Handler = async (req: CreateLinkReq, res) => {
};
export const edit: Handler = async (req, res) => {
const { address, target, description } = req.body;
const { address, target, description, expire_in } = req.body;
if (!address && !target) {
throw new CustomError("Should at least update one field.");
@ -144,7 +153,8 @@ export const edit: Handler = async (req, res) => {
{
...(address && { address }),
...(description && { description }),
...(target && { target })
...(target && { target }),
...(expire_in && { expire_in })
}
);

+ 1
- 0
server/handlers/types.d.ts View File

@ -6,6 +6,7 @@ export interface CreateLinkReq extends Request {
password?: string;
customurl?: string;
description?: string;
expire_in?: string;
domain?: Domain;
target: string;
};

+ 35
- 2
server/handlers/validators.ts View File

@ -1,11 +1,12 @@
import { body, param } from "express-validator";
import { isAfter, subDays, subHours } from "date-fns";
import { isAfter, subDays, subHours, addMilliseconds } from "date-fns";
import urlRegex from "url-regex";
import { promisify } from "util";
import bcrypt from "bcryptjs";
import axios from "axios";
import dns from "dns";
import URL from "url";
import ms from "ms";
import { CustomError, addProtocol } from "../utils";
import query from "../queries";
@ -87,6 +88,22 @@ export const createLink = [
.trim()
.isLength({ min: 0, max: 2040 })
.withMessage("Description length must be between 0 and 2040."),
body("expire_in")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.custom(value => {
try {
return !!ms(value);
} catch {
return false;
}
})
.withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
.customSanitizer(ms)
.custom(value => value >= ms("1m"))
.withMessage("Minimum expire time should be '1 minute'.")
.customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
body("domain")
.optional({ nullable: true, checkFalsy: true })
.custom(checkUser)
@ -138,8 +155,24 @@ export const editLink = [
.withMessage("Custom URL is not valid")
.custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
.withMessage("You can't use this custom URL."),
body("expire_in")
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.custom(value => {
try {
return !!ms(value);
} catch {
return false;
}
})
.withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
.customSanitizer(ms)
.custom(value => value >= ms("1m"))
.withMessage("Minimum expire time should be '1 minute'.")
.customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
body("description")
.optional()
.optional({ nullable: true, checkFalsy: true })
.isString()
.trim()
.isLength({ min: 0, max: 2040 })

+ 14
- 0
server/migrations/20200730203154_expire_in.ts View File

@ -0,0 +1,14 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<any> {
const hasExpireIn = await knex.schema.hasColumn("links", "expire_in");
if (!hasExpireIn) {
await knex.schema.alterTable("links", table => {
table.dateTime("expire_in");
});
}
}
export async function down(): Promise<any> {
return null;
}

+ 1
- 0
server/models/link.ts View File

@ -23,6 +23,7 @@ export async function createLinkTable(knex: Knex) {
.references("id")
.inTable("domains");
table.string("password");
table.dateTime("expire_in");
table.string("target", 2040).notNullable();
table
.integer("user_id")

+ 18
- 0
server/queries/link.ts View File

@ -13,6 +13,7 @@ const selectable = [
"links.updated_at",
"links.password",
"links.description",
"links.expire_in",
"links.target",
"links.visit_count",
"links.user_id",
@ -135,6 +136,7 @@ export const create = async (params: Create) => {
user_id: params.user_id || null,
address: params.address,
description: params.description || null,
expire_in: params.expire_in || null,
target: params.target
},
"*"
@ -161,6 +163,22 @@ export const remove = async (match: Partial) => {
return !!deletedLink;
};
export const batchRemove = async (match: Match<Link>) => {
const deleteQuery = knex<Link>("links");
const findQuery = knex<Link>("links");
Object.entries(match).forEach(([key, value]) => {
findQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
deleteQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
const links = await findQuery;
links.forEach(redis.remove.link);
await deleteQuery.delete();
};
export const update = async (match: Partial<Link>, update: Partial<Link>) => {
const links = await knex<Link>("links")
.where(match)

Loading…
Cancel
Save