Procházet zdrojové kódy

feat: add link expiration

poeti8 před 5 roky
rodič
revize
00fc1faed9

+ 49 - 6
client/components/LinksTable.tsx

@@ -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<RowProps> = ({ 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<RowProps> = ({ 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<RowProps> = ({ 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<RowProps> = ({ 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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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<Link>) => {
   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)