Преглед изворни кода

feat: add api to update links

Resolves #79
poeti8 пре 5 година
родитељ
комит
b781a82a2f

+ 21 - 0
client/components/Icon/EditAlt.tsx

@@ -0,0 +1,21 @@
+import React from "react";
+
+function EditAlt() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="48"
+      height="48"
+      fill="none"
+      stroke="#5c666b"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      viewBox="0 0 24 24"
+    >
+      <path d="M16 3L21 8 8 21 3 21 3 16 16 3z"></path>
+    </svg>
+  );
+}
+
+export default React.memo(EditAlt);

+ 2 - 0
client/components/Icon/Icon.tsx

@@ -12,6 +12,7 @@ import PieChart from "./PieChart";
 import Refresh from "./Refresh";
 import Spinner from "./Spinner";
 import Shuffle from "./Shuffle";
+import EditAlt from "./EditAlt";
 import QRCode from "./QRCode";
 import Signup from "./Signup";
 import Trash from "./Trash";
@@ -36,6 +37,7 @@ const icons = {
   clipboard: Clipboard,
   copy: Copy,
   edit: Edit,
+  editAlt: EditAlt,
   heart: Heart,
   key: Key,
   lock: Lock,

+ 123 - 2
client/components/LinksTable.tsx

@@ -65,6 +65,11 @@ Td.defaultProps = {
   px: [12, 12, 3]
 };
 
+const EditContent = styled(Col)`
+  border-bottom: 1px solid ${Colors.TableHeadBorder};
+  background-color: #fafafa;
+`;
+
 const Action = (props: React.ComponentProps<typeof Icon>) => (
   <Icon
     as="button"
@@ -101,15 +106,31 @@ interface BanForm {
   domain: boolean;
 }
 
+interface EditForm {
+  target: string;
+  address: string;
+}
+
 const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
   const isAdmin = useStoreState(s => s.auth.isAdmin);
   const ban = useStoreActions(s => s.links.ban);
-  const [formState, { checkbox }] = useFormState<BanForm>();
+  const edit = useStoreActions(s => s.links.edit);
+  const [banFormState, { checkbox }] = useFormState<BanForm>();
+  const [editFormState, { text, label }] = useFormState<EditForm>(
+    {
+      target: link.target,
+      address: link.address
+    },
+    { withIds: true }
+  );
   const [copied, setCopied] = useState(false);
+  const [showEdit, setShowEdit] = useState(false);
   const [qrModal, setQRModal] = useState(false);
   const [banModal, setBanModal] = useState(false);
   const [banLoading, setBanLoading] = useState(false);
   const [banMessage, setBanMessage] = useMessage();
+  const [editLoading, setEditLoading] = useState(false);
+  const [editMessage, setEditMessage] = useMessage();
 
   const onCopy = () => {
     setCopied(true);
@@ -121,7 +142,7 @@ const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
   const onBan = async () => {
     setBanLoading(true);
     try {
-      const res = await ban({ id: link.id, ...formState.values });
+      const res = await ban({ id: link.id, ...banFormState.values });
       setBanMessage(res.message, "green");
       setTimeout(() => {
         setBanModal(false);
@@ -132,6 +153,25 @@ const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
     setBanLoading(false);
   };
 
+  const onEdit = async e => {
+    e.preventDefault();
+    if (editLoading) return;
+    setEditLoading(true);
+    try {
+      await edit({ id: link.id, ...editFormState.values });
+      setShowEdit(false);
+    } catch (err) {
+      setEditMessage(errorMessage(err));
+    }
+    setEditLoading(false);
+  };
+
+  const toggleEdit = () => {
+    setShowEdit(s => !s);
+    if (showEdit) editFormState.reset();
+    setEditMessage("");
+  };
+
   return (
     <>
       <Tr key={link.id}>
@@ -225,6 +265,13 @@ const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
             backgroundColor={Colors.QrCodeIconBg}
             onClick={() => setQRModal(true)}
           />
+          <Action
+            name="editAlt"
+            strokeWidth="2.5"
+            stroke={Colors.EditIcon}
+            backgroundColor={Colors.EditIconBg}
+            onClick={toggleEdit}
+          />
           {isAdmin && !link.banned && (
             <Action
               name="stop"
@@ -244,6 +291,80 @@ const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
           />
         </Td>
       </Tr>
+      {showEdit && (
+        <EditContent px={[3, 3, 24]} py={[3, 3, 24]}>
+          <Col as="form" alignItems="flex-start" onSubmit={onEdit}>
+            <Flex alignItems="flex-start">
+              <Col alignItems="flex-start" mr={[0, 3, 3]}>
+                <Text
+                  {...label("target")}
+                  as="label"
+                  mb={2}
+                  fontSize={[14, 15]}
+                  bold
+                >
+                  Target:
+                </Text>
+                <Flex as="form">
+                  <TextInput
+                    {...text("target")}
+                    placeholder="Target..."
+                    placeholderSize={[13, 14]}
+                    fontSize={[14, 15]}
+                    height={[40, 44]}
+                    width={[1, 300, 420]}
+                    pl={[3, 24]}
+                    pr={[3, 24]}
+                    required
+                  />
+                </Flex>
+              </Col>
+              <Col alignItems="flex-start">
+                <Text
+                  {...label("address")}
+                  as="label"
+                  mb={2}
+                  fontSize={[14, 15]}
+                  bold
+                >
+                  {link.domain || process.env.DEFAULT_DOMAIN}/
+                </Text>
+                <Flex as="form">
+                  <TextInput
+                    {...text("address")}
+                    placeholder="Custom address..."
+                    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"
+              mt={3}
+              height={[30, 38]}
+              disabled={editLoading}
+            >
+              <Icon
+                name={editLoading ? "spinner" : "refresh"}
+                stroke="white"
+                mr={2}
+              />
+              {editLoading ? "Updating..." : "Update"}
+            </Button>
+            {editMessage.text && (
+              <Text mt={3} fontSize={15} color={editMessage.color}>
+                {editMessage.text}
+              </Text>
+            )}
+          </Col>
+        </EditContent>
+      )}
       <Modal
         id="table-qrcode-modal"
         minWidth="max-content"

+ 2 - 2
client/components/Shortener.tsx

@@ -278,7 +278,7 @@ const Shortener = () => {
             </Text>
             <TextInput
               {...text("customurl")}
-              placeholder="Custom address"
+              placeholder="Custom address..."
               autocomplete="off"
               data-lpignore
               pl={[3, 24]}
@@ -301,7 +301,7 @@ const Shortener = () => {
             </Text>
             <TextInput
               {...password("password")}
-              placeholder="Password"
+              placeholder="Password..."
               autocomplete="off"
               data-lpignore
               pl={[3, 24]}

+ 6 - 0
client/components/Table.ts

@@ -30,26 +30,32 @@ const Table = styled(Flex)<{ scrollWidth?: string }>`
   tr {
     border-bottom: 1px solid ${Colors.TableHeadBorder};
   }
+
   tbody {
     border-bottom-right-radius: 12px;
     border-bottom-left-radius: 12px;
     overflow: hidden;
   }
+
   tbody + tfoot {
     border: none;
   }
+
   tbody tr:hover {
     background-color: ${Colors.TableRowHover};
   }
+
   thead {
     background-color: ${Colors.TableHeadBg};
     border-top-right-radius: 12px;
     border-top-left-radius: 12px;
     font-weight: bold;
+
     tr {
       border-bottom: 1px solid ${Colors.TableBorder};
     }
   }
+
   tfoot {
     background-color: ${Colors.TableHeadBg};
     border-bottom-right-radius: 12px;

+ 2 - 0
client/consts/consts.ts

@@ -32,6 +32,8 @@ export enum Colors {
   StopIconBg = "hsl(10, 100%, 96%)",
   QrCodeIcon = "hsl(0, 0%, 35%)",
   QrCodeIconBg = "hsl(0, 0%, 94%)",
+  EditIcon = "hsl(46, 90%, 50%)",
+  EditIconBg = "hsl(46, 100%, 94%)",
   PieIcon = "hsl(260, 100%, 69%)",
   PieIconBg = "hsl(260, 100%, 96%)",
   TableHeadBg = "hsl(200, 12%, 95%)",

+ 15 - 0
client/store/links.ts

@@ -38,6 +38,12 @@ export interface BanLink {
   userLinks?: boolean;
 }
 
+export interface EditLink {
+  id: string;
+  target: string;
+  address: string;
+}
+
 export interface LinksQuery {
   limit: string;
   skip: string;
@@ -63,6 +69,7 @@ export interface Links {
   set: Action<Links, LinksListRes>;
   update: Action<Links, Partial<Link>>;
   remove: Thunk<Links, string>;
+  edit: Thunk<Links, EditLink>;
   ban: Thunk<Links, BanLink>;
   setLoading: Action<Links, boolean>;
 }
@@ -102,6 +109,14 @@ export const links: Links = {
     actions.update({ id, banned: true });
     return res.data;
   }),
+  edit: thunk(async (actions, { id, ...payload }) => {
+    const res = await axios.patch(
+      `${APIv2.Links}/${id}`,
+      payload,
+      getAxiosConfig()
+    );
+    actions.update(res.data);
+  }),
   add: action((state, payload) => {
     state.items.pop();
     state.items.unshift(payload);

+ 610 - 526
docs/api/api.ts

@@ -1,529 +1,613 @@
-import * as p from '../../package.json';
+import * as p from "../../package.json";
 
 export default {
-	openapi: '3.0.0',
-	info: {
-		title: "Kutt.it",
-		description: "API referrence for [http://kutt.it](http://kutt.it).\n",
-		version: p.version
-	},
-	servers: [{
-		url: "https://kutt.it/api/v2"
-	}],
-	tags: [{
-		name: "health"
-	}, {
-		name: "links"
-	}, {
-		name: "domains"
-	}, {
-		name: "users"
-	}],
-	paths: {
-		'/health': {
-			get: {
-				tags: ["health"],
-				summary: "API health",
-				responses: {
-					200: {
-						description: "Health",
-						content: {
-							'text/html': {
-								example: "OK"
-							}
-						}
-					}
-				}
-			}
-		},
-		'/links': {
-			get: {
-				tags: ["links"],
-				description: "Get list of links",
-				parameters: [{
-					name: "limit",
-					in: "query",
-					description: "Limit",
-					required: false,
-					style: "form",
-					explode: true,
-					schema: {
-						type: "number",
-						example: 10
-					}
-				}, {
-					name: "skip",
-					in: "query",
-					description: "Skip",
-					required: false,
-					style: "form",
-					explode: true,
-					schema: {
-						type: "number",
-						example: 0
-					}
-				}, {
-					name: "all",
-					in: "query",
-					description: "All links (ADMIN only)",
-					required: false,
-					style: "form",
-					explode: true,
-					schema: {
-						type: "boolean",
-						example: false
-					}
-				}],
-				responses: {
-					200: {
-						description: "List of links",
-						content: {
-							'application/json': {
-								schema: {
-									$ref: "#/components/schemas/inline_response_200"
-								}
-							}
-						}
-					}
-				},
-				security: [{
-					APIKeyAuth: []
-				}]
-			},
-			post: {
-				tags: ["links"],
-				description: "Create a short link",
-				requestBody: {
-					content: {
-						'application/json': {
-							schema: {
-								$ref: "#/components/schemas/body"
-							}
-						}
-					}
-				},
-				responses: {
-					200: {
-						description: "Craeted link",
-						content: {
-							'application/json': {
-								schema: {
-									$ref: "#/components/schemas/Link"
-								}
-							}
-						}
-					}
-				},
-				security: [{
-					APIKeyAuth: []
-				}]
-			}
-		},
-		'/links/{id}': {
-			delete: {
-				tags: ["links"],
-				description: "Delete a link",
-				parameters: [{
-					name: "id",
-					in: "path",
-					required: true,
-					style: "simple",
-					explode: false,
-					schema: {
-						type: "string",
-						format: "uuid"
-					}
-				}],
-				responses: {
-					200: {
-						description: "Deleted link successfully",
-						content: {
-							'application/json': {
-								schema: {
-									$ref: "#/components/schemas/inline_response_200_1"
-								}
-							}
-						}
-					}
-				},
-				security: [{
-					APIKeyAuth: []
-				}]
-			}
-		},
-		'/links/{id}/stats': {
-			get: {
-				tags: ["links"],
-				description: "Get link stats",
-				parameters: [{
-					name: "id",
-					in: "path",
-					required: true,
-					style: "simple",
-					explode: false,
-					schema: {
-						type: "string",
-						format: "uuid"
-					}
-				}],
-				responses: {
-					200: {
-						description: "Link stats",
-						content: {
-							'application/json': {
-								schema: {
-									$ref: "#/components/schemas/Stats"
-								}
-							}
-						}
-					}
-				},
-				security: [{
-					APIKeyAuth: []
-				}]
-			}
-		},
-		'/domains': {
-			post: {
-				tags: ["domains"],
-				description: "Create a domain",
-				requestBody: {
-					content: {
-						'application/json': {
-							schema: {
-								$ref: "#/components/schemas/body_1"
-							}
-						}
-					}
-				},
-				responses: {
-					200: {
-						description: "Created domain",
-						content: {
-							'application/json': {
-								schema: {
-									$ref: "#/components/schemas/Domain"
-								}
-							}
-						}
-					}
-				},
-				security: [{
-					APIKeyAuth: []
-				}]
-			}
-		},
-		'/domains/{id}': {
-			delete: {
-				tags: ["domains"],
-				description: "Delete a domain",
-				parameters: [{
-					name: "id",
-					in: "path",
-					required: true,
-					style: "simple",
-					explode: false,
-					schema: {
-						type: "string",
-						format: "uuid"
-					}
-				}],
-				responses: {
-					200: {
-						description: "Deleted domain successfully",
-						content: {
-							'application/json': {
-								schema: {
-									$ref: "#/components/schemas/inline_response_200_1"
-								}
-							}
-						}
-					}
-				},
-				security: [{
-					APIKeyAuth: []
-				}]
-			}
-		},
-		'/users': {
-			get: {
-				tags: ["users"],
-				description: "Get user info",
-				responses: {
-					200: {
-						description: "User info",
-						content: {
-							'application/json': {
-								schema: {
-									$ref: "#/components/schemas/User"
-								}
-							}
-						}
-					}
-				},
-				security: [{
-					APIKeyAuth: []
-				}]
-			}
-		}
-	},
-	components: {
-		schemas: {
-			Link: {
-				type: "object",
-				properties: {
-					address: {
-						type: "string"
-					},
-					banned: {
-						type: "boolean",
-						default: false
-					},
-					created_at: {
-						type: "string",
-						format: "date-time"
-					},
-					id: {
-						type: "string",
-						format: "uuid"
-					},
-					link: {
-						type: "string"
-					},
-					password: {
-						type: "boolean",
-						default: false
-					},
-					target: {
-						type: "string"
-					},
-					updated_at: {
-						type: "string",
-						format: "date-time"
-					},
-					visit_count: {
-						type: "number"
-					}
-				}
-			},
-			Domain: {
-				type: "object",
-				properties: {
-					address: {
-						type: "string"
-					},
-					banned: {
-						type: "boolean",
-						default: false
-					},
-					created_at: {
-						type: "string",
-						format: "date-time"
-					},
-					id: {
-						type: "string",
-						format: "uuid"
-					},
-					homepage: {
-						type: "string"
-					},
-					updated_at: {
-						type: "string",
-						format: "date-time"
-					}
-				}
-			},
-			User: {
-				type: "object",
-				properties: {
-					apikey: {
-						type: "string"
-					},
-					email: {
-						type: "string"
-					},
-					domains: {
-						type: "array",
-						items: {
-							$ref: "#/components/schemas/Domain"
-						}
-					}
-				}
-			},
-			StatsItem: {
-				type: "object",
-				properties: {
-					stats: {
-						$ref: "#/components/schemas/StatsItem_stats"
-					},
-					views: {
-						type: "array",
-						items: {
-							type: "number"
-						}
-					}
-				}
-			},
-			Stats: {
-				type: "object",
-				properties: {
-					allTime: {
-						$ref: "#/components/schemas/StatsItem"
-					},
-					lastDay: {
-						$ref: "#/components/schemas/StatsItem"
-					},
-					lastMonth: {
-						$ref: "#/components/schemas/StatsItem"
-					},
-					lastWeek: {
-						$ref: "#/components/schemas/StatsItem"
-					},
-					updatedAt: {
-						type: "string"
-					},
-					address: {
-						type: "string"
-					},
-					banned: {
-						type: "boolean",
-						default: false
-					},
-					created_at: {
-						type: "string",
-						format: "date-time"
-					},
-					id: {
-						type: "string",
-						format: "uuid"
-					},
-					link: {
-						type: "string"
-					},
-					password: {
-						type: "boolean",
-						default: false
-					},
-					target: {
-						type: "string"
-					},
-					updated_at: {
-						type: "string",
-						format: "date-time"
-					},
-					visit_count: {
-						type: "number"
-					}
-				}
-			},
-			inline_response_200: {
-				properties: {
-					limit: {
-						type: "number",
-						default: 10
-					},
-					skip: {
-						type: "number",
-						default: 0
-					},
-					total: {
-						type: "number",
-						default: 0
-					},
-					data: {
-						type: "array",
-						items: {
-							$ref: "#/components/schemas/Link"
-						}
-					}
-				}
-			},
-			body: {
-				required: ["target"],
-				properties: {
-					target: {
-						type: "string"
-					},
-					password: {
-						type: "string"
-					},
-					customurl: {
-						type: "string"
-					},
-					reuse: {
-						type: "boolean",
-						default: false
-					},
-					domain: {
-						type: "string"
-					}
-				}
-			},
-			inline_response_200_1: {
-				properties: {
-					message: {
-						type: "string"
-					}
-				}
-			},
-			body_1: {
-				required: ["address"],
-				properties: {
-					address: {
-						type: "string"
-					},
-					homepage: {
-						type: "string"
-					}
-				}
-			},
-			StatsItem_stats_browser: {
-				type: "object",
-				properties: {
-					name: {
-						type: "string"
-					},
-					value: {
-						type: "number"
-					}
-				}
-			},
-			StatsItem_stats: {
-				type: "object",
-				properties: {
-					browser: {
-						type: "array",
-						items: {
-							$ref: "#/components/schemas/StatsItem_stats_browser"
-						}
-					},
-					os: {
-						type: "array",
-						items: {
-							$ref: "#/components/schemas/StatsItem_stats_browser"
-						}
-					},
-					country: {
-						type: "array",
-						items: {
-							$ref: "#/components/schemas/StatsItem_stats_browser"
-						}
-					},
-					referrer: {
-						type: "array",
-						items: {
-							$ref: "#/components/schemas/StatsItem_stats_browser"
-						}
-					}
-				}
-			}
-		},
-		securitySchemes: {
-			APIKeyAuth: {
-				type: "apiKey",
-				name: "X-API-KEY",
-				in: "header"
-			}
-		}
-	}
+  openapi: "3.0.0",
+  info: {
+    title: "Kutt.it",
+    description: "API referrence for [http://kutt.it](http://kutt.it).\n",
+    version: p.version
+  },
+  servers: [
+    {
+      url: "https://kutt.it/api/v2"
+    }
+  ],
+  tags: [
+    {
+      name: "health"
+    },
+    {
+      name: "links"
+    },
+    {
+      name: "domains"
+    },
+    {
+      name: "users"
+    }
+  ],
+  paths: {
+    "/health": {
+      get: {
+        tags: ["health"],
+        summary: "API health",
+        responses: {
+          "200": {
+            description: "Health",
+            content: {
+              "text/html": {
+                example: "OK"
+              }
+            }
+          }
+        }
+      }
+    },
+    "/links": {
+      get: {
+        tags: ["links"],
+        description: "Get list of links",
+        parameters: [
+          {
+            name: "limit",
+            in: "query",
+            description: "Limit",
+            required: false,
+            style: "form",
+            explode: true,
+            schema: {
+              type: "number",
+              example: 10
+            }
+          },
+          {
+            name: "skip",
+            in: "query",
+            description: "Skip",
+            required: false,
+            style: "form",
+            explode: true,
+            schema: {
+              type: "number",
+              example: 0
+            }
+          },
+          {
+            name: "all",
+            in: "query",
+            description: "All links (ADMIN only)",
+            required: false,
+            style: "form",
+            explode: true,
+            schema: {
+              type: "boolean",
+              example: false
+            }
+          }
+        ],
+        responses: {
+          "200": {
+            description: "List of links",
+            content: {
+              "application/json": {
+                schema: {
+                  $ref: "#/components/schemas/inline_response_200"
+                }
+              }
+            }
+          }
+        },
+        security: [
+          {
+            APIKeyAuth: []
+          }
+        ]
+      },
+      post: {
+        tags: ["links"],
+        description: "Create a short link",
+        requestBody: {
+          content: {
+            "application/json": {
+              schema: {
+                $ref: "#/components/schemas/body"
+              }
+            }
+          }
+        },
+        responses: {
+          "200": {
+            description: "Craeted link",
+            content: {
+              "application/json": {
+                schema: {
+                  $ref: "#/components/schemas/Link"
+                }
+              }
+            }
+          }
+        },
+        security: [
+          {
+            APIKeyAuth: []
+          }
+        ]
+      }
+    },
+    "/links/{id}": {
+      delete: {
+        tags: ["links"],
+        description: "Delete a link",
+        parameters: [
+          {
+            name: "id",
+            in: "path",
+            required: true,
+            style: "simple",
+            explode: false,
+            schema: {
+              type: "string",
+              format: "uuid"
+            }
+          }
+        ],
+        responses: {
+          "200": {
+            description: "Deleted link successfully",
+            content: {
+              "application/json": {
+                schema: {
+                  $ref: "#/components/schemas/inline_response_200_1"
+                }
+              }
+            }
+          }
+        },
+        security: [
+          {
+            APIKeyAuth: []
+          }
+        ]
+      },
+      patch: {
+        tags: ["links"],
+        description: "Update a link",
+        parameters: [
+          {
+            name: "id",
+            in: "path",
+            required: true,
+            style: "simple",
+            explode: false,
+            schema: {
+              type: "string",
+              format: "uuid"
+            }
+          }
+        ],
+        requestBody: {
+          content: {
+            "application/json": {
+              schema: {
+                $ref: "#/components/schemas/body_1"
+              }
+            }
+          }
+        },
+        responses: {
+          "200": {
+            description: "Updated link successfully",
+            content: {
+              "application/json": {
+                schema: {
+                  $ref: "#/components/schemas/Link"
+                }
+              }
+            }
+          }
+        },
+        security: [
+          {
+            APIKeyAuth: []
+          }
+        ]
+      }
+    },
+    "/links/{id}/stats": {
+      get: {
+        tags: ["links"],
+        description: "Get link stats",
+        parameters: [
+          {
+            name: "id",
+            in: "path",
+            required: true,
+            style: "simple",
+            explode: false,
+            schema: {
+              type: "string",
+              format: "uuid"
+            }
+          }
+        ],
+        responses: {
+          "200": {
+            description: "Link stats",
+            content: {
+              "application/json": {
+                schema: {
+                  $ref: "#/components/schemas/Stats"
+                }
+              }
+            }
+          }
+        },
+        security: [
+          {
+            APIKeyAuth: []
+          }
+        ]
+      }
+    },
+    "/domains": {
+      post: {
+        tags: ["domains"],
+        description: "Create a domain",
+        requestBody: {
+          content: {
+            "application/json": {
+              schema: {
+                $ref: "#/components/schemas/body_2"
+              }
+            }
+          }
+        },
+        responses: {
+          "200": {
+            description: "Created domain",
+            content: {
+              "application/json": {
+                schema: {
+                  $ref: "#/components/schemas/Domain"
+                }
+              }
+            }
+          }
+        },
+        security: [
+          {
+            APIKeyAuth: []
+          }
+        ]
+      }
+    },
+    "/domains/{id}": {
+      delete: {
+        tags: ["domains"],
+        description: "Delete a domain",
+        parameters: [
+          {
+            name: "id",
+            in: "path",
+            required: true,
+            style: "simple",
+            explode: false,
+            schema: {
+              type: "string",
+              format: "uuid"
+            }
+          }
+        ],
+        responses: {
+          "200": {
+            description: "Deleted domain successfully",
+            content: {
+              "application/json": {
+                schema: {
+                  $ref: "#/components/schemas/inline_response_200_1"
+                }
+              }
+            }
+          }
+        },
+        security: [
+          {
+            APIKeyAuth: []
+          }
+        ]
+      }
+    },
+    "/users": {
+      get: {
+        tags: ["users"],
+        description: "Get user info",
+        responses: {
+          "200": {
+            description: "User info",
+            content: {
+              "application/json": {
+                schema: {
+                  $ref: "#/components/schemas/User"
+                }
+              }
+            }
+          }
+        },
+        security: [
+          {
+            APIKeyAuth: []
+          }
+        ]
+      }
+    }
+  },
+  components: {
+    schemas: {
+      Link: {
+        type: "object",
+        properties: {
+          address: {
+            type: "string"
+          },
+          banned: {
+            type: "boolean",
+            default: false
+          },
+          created_at: {
+            type: "string",
+            format: "date-time"
+          },
+          id: {
+            type: "string",
+            format: "uuid"
+          },
+          link: {
+            type: "string"
+          },
+          password: {
+            type: "boolean",
+            default: false
+          },
+          target: {
+            type: "string"
+          },
+          updated_at: {
+            type: "string",
+            format: "date-time"
+          },
+          visit_count: {
+            type: "number"
+          }
+        }
+      },
+      Domain: {
+        type: "object",
+        properties: {
+          address: {
+            type: "string"
+          },
+          banned: {
+            type: "boolean",
+            default: false
+          },
+          created_at: {
+            type: "string",
+            format: "date-time"
+          },
+          id: {
+            type: "string",
+            format: "uuid"
+          },
+          homepage: {
+            type: "string"
+          },
+          updated_at: {
+            type: "string",
+            format: "date-time"
+          }
+        }
+      },
+      User: {
+        type: "object",
+        properties: {
+          apikey: {
+            type: "string"
+          },
+          email: {
+            type: "string"
+          },
+          domains: {
+            type: "array",
+            items: {
+              $ref: "#/components/schemas/Domain"
+            }
+          }
+        }
+      },
+      StatsItem: {
+        type: "object",
+        properties: {
+          stats: {
+            $ref: "#/components/schemas/StatsItem_stats"
+          },
+          views: {
+            type: "array",
+            items: {
+              type: "number"
+            }
+          }
+        }
+      },
+      Stats: {
+        type: "object",
+        properties: {
+          allTime: {
+            $ref: "#/components/schemas/StatsItem"
+          },
+          lastDay: {
+            $ref: "#/components/schemas/StatsItem"
+          },
+          lastMonth: {
+            $ref: "#/components/schemas/StatsItem"
+          },
+          lastWeek: {
+            $ref: "#/components/schemas/StatsItem"
+          },
+          updatedAt: {
+            type: "string"
+          },
+          address: {
+            type: "string"
+          },
+          banned: {
+            type: "boolean",
+            default: false
+          },
+          created_at: {
+            type: "string",
+            format: "date-time"
+          },
+          id: {
+            type: "string",
+            format: "uuid"
+          },
+          link: {
+            type: "string"
+          },
+          password: {
+            type: "boolean",
+            default: false
+          },
+          target: {
+            type: "string"
+          },
+          updated_at: {
+            type: "string",
+            format: "date-time"
+          },
+          visit_count: {
+            type: "number"
+          }
+        }
+      },
+      inline_response_200: {
+        properties: {
+          limit: {
+            type: "number",
+            default: 10
+          },
+          skip: {
+            type: "number",
+            default: 0
+          },
+          total: {
+            type: "number",
+            default: 0
+          },
+          data: {
+            type: "array",
+            items: {
+              $ref: "#/components/schemas/Link"
+            }
+          }
+        }
+      },
+      body: {
+        required: ["target"],
+        properties: {
+          target: {
+            type: "string"
+          },
+          password: {
+            type: "string"
+          },
+          customurl: {
+            type: "string"
+          },
+          reuse: {
+            type: "boolean",
+            default: false
+          },
+          domain: {
+            type: "string"
+          }
+        }
+      },
+      inline_response_200_1: {
+        properties: {
+          message: {
+            type: "string"
+          }
+        }
+      },
+      body_1: {
+        properties: {
+          target: {
+            type: "string"
+          },
+          address: {
+            type: "string"
+          }
+        }
+      },
+      body_2: {
+        required: ["address"],
+        properties: {
+          address: {
+            type: "string"
+          },
+          homepage: {
+            type: "string"
+          }
+        }
+      },
+      StatsItem_stats_browser: {
+        type: "object",
+        properties: {
+          name: {
+            type: "string"
+          },
+          value: {
+            type: "number"
+          }
+        }
+      },
+      StatsItem_stats: {
+        type: "object",
+        properties: {
+          browser: {
+            type: "array",
+            items: {
+              $ref: "#/components/schemas/StatsItem_stats_browser"
+            }
+          },
+          os: {
+            type: "array",
+            items: {
+              $ref: "#/components/schemas/StatsItem_stats_browser"
+            }
+          },
+          country: {
+            type: "array",
+            items: {
+              $ref: "#/components/schemas/StatsItem_stats_browser"
+            }
+          },
+          referrer: {
+            type: "array",
+            items: {
+              $ref: "#/components/schemas/StatsItem_stats_browser"
+            }
+          }
+        }
+      }
+    },
+    securitySchemes: {
+      APIKeyAuth: {
+        type: "apiKey",
+        name: "X-API-KEY",
+        in: "header"
+      }
+    }
+  }
 };

+ 51 - 0
server/handlers/links.ts

@@ -98,6 +98,57 @@ export const create: Handler = async (req: CreateLinkReq, res) => {
     .send(utils.sanitize.link({ ...link, domain: domain?.address }));
 };
 
+export const edit: Handler = async (req, res) => {
+  const { address, target } = req.body;
+
+  if (!address && !target) {
+    throw new CustomError("Should at least update one field.");
+  }
+
+  const link = await query.link.find({
+    uuid: req.params.id,
+    ...(!req.user.admin && { user_id: req.user.id })
+  });
+
+  if (!link) {
+    throw new CustomError("Link was not found.");
+  }
+
+  const targetDomain = URL.parse(target).hostname;
+  const domain_id = link.domain_id;
+
+  const queries = await Promise.all([
+    validators.cooldown(req.user),
+    validators.malware(req.user, target),
+    address !== link.address &&
+      query.link.find({
+        address,
+        user_id: req.user.id,
+        domain_id
+      }),
+    validators.bannedDomain(targetDomain),
+    validators.bannedHost(targetDomain)
+  ]);
+
+  // Check if custom link already exists
+  if (queries[2]) {
+    throw new CustomError("Custom URL is already in use.");
+  }
+
+  // Update link
+  const [updatedLink] = await query.link.update(
+    {
+      id: link.id
+    },
+    {
+      ...(address && { address }),
+      ...(target && { target })
+    }
+  );
+
+  return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
+};
+
 export const remove: Handler = async (req, res) => {
   const link = await query.link.remove({
     uuid: req.params.id,

+ 31 - 0
server/handlers/validators.ts

@@ -100,6 +100,37 @@ export const createLink = [
     .withMessage("You can't use this domain.")
 ];
 
+export const editLink = [
+  body("target")
+    .optional({ checkFalsy: true, nullable: true })
+    .isString()
+    .trim()
+    .isLength({ min: 1, max: 2040 })
+    .withMessage("Maximum URL length is 2040.")
+    .customSanitizer(addProtocol)
+    .custom(
+      value =>
+        urlRegex({ exact: true, strict: false }).test(value) ||
+        /^(?!https?)(\w+):\/\//.test(value)
+    )
+    .withMessage("URL is not valid.")
+    .custom(value => URL.parse(value).host !== env.DEFAULT_DOMAIN)
+    .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
+  body("address")
+    .optional({ checkFalsy: true, nullable: true })
+    .isString()
+    .trim()
+    .isLength({ min: 1, max: 64 })
+    .withMessage("Custom URL length must be between 1 and 64.")
+    .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
+    .withMessage("Custom URL is not valid")
+    .custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
+    .withMessage("You can't use this custom URL."),
+  param("id", "ID is invalid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 36, max: 36 })
+];
+
 export const redirectProtected = [
   body("password", "Password is invalid.")
     .exists({ checkFalsy: true, checkNull: true })