13 Commits

Author SHA1 Message Date
d42d3fbe10 chore: upgrade version to 0.3.1 2023-07-24 23:22:47 +08:00
6dfccb9509 fix: escape link text to prevent XSS 2023-07-24 22:01:32 +08:00
66876452e1 chore: update seed data 2023-07-24 19:14:24 +08:00
6b107924aa chore: update badges in readme 2023-07-23 23:54:37 +08:00
b84620c057 docs: update readme 2023-07-23 23:49:51 +08:00
c30b6adb8e docs: add install guide 2023-07-23 23:42:36 +08:00
c8fea442d6 chore: fix width overflow 2023-07-23 23:20:34 +08:00
a36a99e53d chore: update search input 2023-07-23 02:12:15 +08:00
86078b097d chore: fix form event in firefox 2023-07-23 02:09:50 +08:00
11205566ac feat: add search input 2023-07-23 01:54:14 +08:00
709118464b chore: update shortcut view style 2023-07-23 01:53:17 +08:00
792b60c480 chore: update autofill in demo 2023-07-22 11:54:23 +08:00
1418fc2209 chore: update demo seed data 2023-07-22 11:43:58 +08:00
15 changed files with 130 additions and 50 deletions

View File

@ -2,10 +2,16 @@
<img align="right" src="./resources/logo.png" height="64px" alt="logo"> <img align="right" src="./resources/logo.png" height="64px" alt="logo">
**Slash** is a bookmarking and short link service that allows you to save and share links easily. It lets you store and categorize links, generate short URLs for easy sharing, search and filter your saved links, and access them from any device. `Slash` is a bookmarking and link shortening service that enables easy saving and sharing of links. It allows you to store, categorize, and share links with custom short URLs. You can search, filter, and access your saved links from any device. It also supports team sharing of link libraries for easy collaboration.
Try it out on <a href="https://slash.stevenlgtm.com">Live Demo</a>. Try it out on <a href="https://slash.stevenlgtm.com">Live Demo</a>.
<p>
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
<a href="https://hub.docker.com/r/stevenlgtm/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/stevenlgtm/slash.svg" /></a>
<a href="https://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github" /></a>
</p>
## Features ## Features
- Create customizable `/s/` short links for any URL. - Create customizable `/s/` short links for any URL.
@ -15,8 +21,8 @@ Try it out on <a href="https://slash.stevenlgtm.com">Live Demo</a>.
## Deploy with Docker in seconds ## Deploy with Docker in seconds
> This project is under active development.
```bash ```bash
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash stevenlgtm/slash:latest docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash stevenlgtm/slash:latest
``` ```
Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md).

View File

@ -3,6 +3,7 @@ package v1
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -49,7 +50,7 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
func redirectToShortcut(c echo.Context, shortcut *store.Shortcut) error { func redirectToShortcut(c echo.Context, shortcut *store.Shortcut) error {
isValidURL := isValidURLString(shortcut.Link) isValidURL := isValidURLString(shortcut.Link)
if shortcut.OpenGraphMetadata == nil { if shortcut.OpenGraphMetadata == nil || (shortcut.OpenGraphMetadata.Title == "" && shortcut.OpenGraphMetadata.Description == "" && shortcut.OpenGraphMetadata.Image == "") {
if isValidURL { if isValidURL {
return c.Redirect(http.StatusSeeOther, shortcut.Link) return c.Redirect(http.StatusSeeOther, shortcut.Link)
} }
@ -63,6 +64,7 @@ func redirectToShortcut(c echo.Context, shortcut *store.Shortcut) error {
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OpenGraphMetadata.Title), fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OpenGraphMetadata.Title),
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OpenGraphMetadata.Description), fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OpenGraphMetadata.Image), fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OpenGraphMetadata.Image),
`<meta property="og:type" content="website" />`,
// Twitter related metadata. // Twitter related metadata.
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OpenGraphMetadata.Title), fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OpenGraphMetadata.Title),
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OpenGraphMetadata.Description), fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
@ -76,7 +78,7 @@ func redirectToShortcut(c echo.Context, shortcut *store.Shortcut) error {
if isValidURL { if isValidURL {
body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link) body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link)
} else { } else {
body = shortcut.Link body = html.EscapeString(shortcut.Link)
} }
htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body) htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
return c.HTML(http.StatusOK, htmlString) return c.HTML(http.StatusOK, htmlString)

39
docs/install.md Normal file
View File

@ -0,0 +1,39 @@
# Self-hosting Slash with Docker
Slash is designed for self-hosting through Docker. No Docker expertise is required to launch your own instance. Just basic understanding of command line and networking.
## Requirements
The only requirement is a server with Docker installed.
## Docker Run
To deploy Slash using docker run, just one command is needed:
```bash
docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash stevenlgtm/slash:latest
```
This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory.
## Upgrade
To upgrade Slash to latest version, stop and remove the old container first:
```bash
docker stop slash && docker rm slash
```
It's recommended but optional to backup database:
```bash
cp -r ~/.slash/slash_prod.db ~/.slash/slash_prod.db.bak
```
Then pull the latest image:
```bash
docker pull stevenlgtm/slash:latest
```
Finally, restart Slash by following the steps in [Docker Run](#docker-run).

View File

@ -9,10 +9,10 @@ import (
// Version is the service current released version. // Version is the service current released version.
// Semantic versioning: https://semver.org/ // Semantic versioning: https://semver.org/
var Version = "0.3.0" var Version = "0.3.1"
// DevVersion is the service current development version. // DevVersion is the service current development version.
var DevVersion = "0.3.0" var DevVersion = "0.3.1"
func GetCurrentVersion(mode string) string { func GetCurrentVersion(mode string) string {
if mode == "dev" || mode == "demo" { if mode == "dev" || mode == "demo" {

View File

@ -12,8 +12,7 @@ VALUES
'ADMIN', 'ADMIN',
'slash@stevenlgtm.com', 'slash@stevenlgtm.com',
'Slasher', 'Slasher',
-- raw password: secret '$2a$10$H8HBWGcG/hoePhFy5SiNKOHxMD6omIpyEEWbl/fIorFC814bXW.Ua'
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
); );
INSERT INTO INSERT INTO

View File

@ -10,28 +10,11 @@ VALUES
( (
1, 1,
101, 101,
'memos', 'discord',
'https://usememos.com', 'https://discord.gg/QZqUuUAhDV',
'PUBLIC' 'PUBLIC'
); );
INSERT INTO
shortcut (
`id`,
`creator_id`,
`name`,
`link`,
`visibility`
)
VALUES
(
2,
101,
'sqlchat',
'https://www.sqlchat.ai',
'WORKSPACE'
);
INSERT INTO INSERT INTO
shortcut ( shortcut (
`id`, `id`,
@ -43,7 +26,7 @@ INSERT INTO
) )
VALUES VALUES
( (
3, 2,
101, 101,
'ai-infra', 'ai-infra',
'https://star-history.com/blog/open-source-ai-infra-projects', 'https://star-history.com/blog/open-source-ai-infra-projects',
@ -62,7 +45,7 @@ INSERT INTO
) )
VALUES VALUES
( (
4, 3,
101, 101,
'schema-change', 'schema-change',
'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change', 'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change',
@ -70,6 +53,23 @@ VALUES
'{"title":"How to Handle Database Migration / Schema Change?","description":"A database schema is the structure of a database, which describes the relationships between the different tables and fields in the database. A database schema change, also known as schema migration, or simply migration refers to any alteration to this structure, such as adding a new table, modifying the data type of a field, or changing the relationships between tables.","image":"https://www.bytebase.com/_next/image/?url=%2Fcontent%2Fblog%2Fhow-to-handle-database-schema-change%2Fchange.webp\u0026w=2048\u0026q=75"}' '{"title":"How to Handle Database Migration / Schema Change?","description":"A database schema is the structure of a database, which describes the relationships between the different tables and fields in the database. A database schema change, also known as schema migration, or simply migration refers to any alteration to this structure, such as adding a new table, modifying the data type of a field, or changing the relationships between tables.","image":"https://www.bytebase.com/_next/image/?url=%2Fcontent%2Fblog%2Fhow-to-handle-database-schema-change%2Fchange.webp\u0026w=2048\u0026q=75"}'
); );
INSERT INTO
shortcut (
`id`,
`creator_id`,
`name`,
`link`,
`visibility`
)
VALUES
(
4,
101,
'sqlchat',
'https://www.sqlchat.ai',
'WORKSPACE'
);
INSERT INTO INSERT INTO
shortcut ( shortcut (
`id`, `id`,

View File

@ -12,6 +12,7 @@
"@mui/joy": "5.0.0-alpha.84", "@mui/joy": "5.0.0-alpha.84",
"@reduxjs/toolkit": "^1.8.1", "@reduxjs/toolkit": "^1.8.1",
"axios": "^0.27.2", "axios": "^0.27.2",
"classnames": "^2.3.2",
"copy-to-clipboard": "^3.3.2", "copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.3", "dayjs": "^1.11.3",
"i18next": "^23.2.3", "i18next": "^23.2.3",

7
web/pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ dependencies:
axios: axios:
specifier: ^0.27.2 specifier: ^0.27.2
version: 0.27.2 version: 0.27.2
classnames:
specifier: ^2.3.2
version: 2.3.2
copy-to-clipboard: copy-to-clipboard:
specifier: ^3.3.2 specifier: ^3.3.2
version: 3.3.2 version: 3.3.2
@ -1263,6 +1266,10 @@ packages:
fsevents: 2.3.2 fsevents: 2.3.2
dev: false dev: false
/classnames@2.3.2:
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
dev: false
/clsx@1.2.1: /clsx@1.2.1:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'} engines: {node: '>=6'}

View File

@ -19,12 +19,11 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
</div> </div>
<div className="max-w-full w-80 sm:w-96"> <div className="max-w-full w-80 sm:w-96">
<p> <p>
<span className="font-medium">Slash</span> is a bookmarking and short link service that allows you to save and share links <span className="font-medium">Slash</span>: A bookmarking and url shortener, save and share your links very easily.
easily.
</p> </p>
<div className="mt-1"> <div className="mt-1">
<span className="mr-2">See more in:</span> <span className="mr-2">See more in</span>
<Link variant="plain" href="https://github.com/boojack/slash"> <Link variant="plain" href="https://github.com/boojack/slash" target="_blank">
GitHub GitHub
</Link> </Link>
</div> </div>

View File

@ -1,4 +1,5 @@
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy"; import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
import classnames from "classnames";
import { isUndefined } from "lodash-es"; import { isUndefined } from "lodash-es";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -182,7 +183,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />
</Button> </Button>
</div> </div>
<div className="overflow-y-auto"> <div className="overflow-y-auto overflow-x-hidden">
<div className="w-full flex flex-col justify-start items-start mb-3"> <div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2"> <span className="mb-2">
Name <span className="text-red-600">*</span> Name <span className="text-red-600">*</span>
@ -227,14 +228,15 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<Divider className="text-gray-500">Optional</Divider> <Divider className="text-gray-500">Optional</Divider>
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3"> <div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3">
<div <div
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${ className={classnames(
showDescriptionAndTag ? "bg-gray-100" : "" "w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100",
}`} showDescriptionAndTag ? "bg-gray-100 border-b" : ""
)}
onClick={() => setShowDescriptionAndTag(!showDescriptionAndTag)} onClick={() => setShowDescriptionAndTag(!showDescriptionAndTag)}
> >
<span className="text-sm">Description and tags</span> <span className="text-sm">Description and tags</span>
<button className="w-7 h-7 p-1 rounded-md"> <button className="w-7 h-7 p-1 rounded-md">
<Icon.ChevronDown className={`w-4 h-auto text-gray-500 ${showDescriptionAndTag ? "transform rotate-180" : ""}`} /> <Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showDescriptionAndTag ? "transform rotate-180" : "")} />
</button> </button>
</div> </div>
{showDescriptionAndTag && ( {showDescriptionAndTag && (
@ -267,7 +269,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden"> <div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden">
<div <div
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${ className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${
showOpenGraphMetadata ? "bg-gray-100" : "" showOpenGraphMetadata ? "bg-gray-100 border-b" : ""
}`} }`}
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)} onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
> >
@ -276,7 +278,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" /> <Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" />
</span> </span>
<button className="w-7 h-7 p-1 rounded-md"> <button className="w-7 h-7 p-1 rounded-md">
<Icon.ChevronDown className={`w-4 h-auto text-gray-500 ${showDescriptionAndTag ? "transform rotate-180" : ""}`} /> <Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} />
</button> </button>
</div> </div>
{showOpenGraphMetadata && ( {showOpenGraphMetadata && (

View File

@ -149,7 +149,7 @@ const ShortcutView = (props: Props) => {
<Tooltip title="Creator" variant="solid" placement="top" arrow> <Tooltip title="Creator" variant="solid" placement="top" arrow>
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm"> <div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
<Icon.User className="w-4 h-auto mr-1" /> <Icon.User className="w-4 h-auto mr-1" />
{shortcut.creator.nickname} <span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span>
</div> </div>
</Tooltip> </Tooltip>
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow> <Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>

View File

@ -1,4 +1,4 @@
import { Button, Tab, TabList, Tabs } from "@mui/joy"; import { Button, Input, Tab, TabList, Tabs } from "@mui/joy";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { shortcutService } from "../services"; import { shortcutService } from "../services";
import { useAppSelector } from "../stores"; import { useAppSelector } from "../stores";
@ -43,8 +43,20 @@ const Home: React.FC = () => {
return ( return (
<> <>
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start"> <div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start">
<div className="w-full flex flex-row justify-start items-center mb-4"> <div className="w-full flex flex-row justify-between items-center mb-4">
<span className="font-mono text-gray-400 mr-2">Shortcuts</span> <span className="font-mono text-gray-400 mr-2">Shortcuts</span>
<Input
className="w-32"
type="text"
size="sm"
placeholder="Search"
startDecorator={<Icon.Search className="w-4 h-auto" />}
endDecorator={
filter.search && <Icon.X className="w-4 h-auto cursor-pointer" onClick={() => viewStore.setFilter({ search: "" })} />
}
value={filter.search}
onChange={(e) => viewStore.setFilter({ search: e.target.value })}
/>
</div> </div>
<div className="w-full flex flex-row justify-between items-center mb-4"> <div className="w-full flex flex-row justify-between items-center mb-4">
<div className="flex flex-row justify-start items-center"> <div className="flex flex-row justify-start items-center">

View File

@ -1,5 +1,5 @@
import { Button, Input } from "@mui/joy"; import { Button, Input } from "@mui/joy";
import React, { useEffect, useState } from "react"; import React, { FormEvent, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import * as api from "../helpers/api"; import * as api from "../helpers/api";
@ -29,7 +29,7 @@ const SignIn: React.FC = () => {
} }
if (mode === "demo") { if (mode === "demo") {
setEmail("slash@stevenlgtm.com"); setEmail("steven@usememos.com");
setPassword("secret"); setPassword("secret");
} }
}, []); }, []);
@ -44,7 +44,8 @@ const SignIn: React.FC = () => {
setPassword(text); setPassword(text);
}; };
const handleSigninBtnClick = async () => { const handleSigninBtnClick = async (e: FormEvent) => {
e.preventDefault();
if (actionBtnLoadingState.isLoading) { if (actionBtnLoadingState.isLoading) {
return; return;
} }

View File

@ -1,5 +1,5 @@
import { Button, Input } from "@mui/joy"; import { Button, Input } from "@mui/joy";
import React, { useEffect, useState } from "react"; import React, { FormEvent, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import * as api from "../helpers/api"; import * as api from "../helpers/api";
@ -48,7 +48,8 @@ const SignUp: React.FC = () => {
setPassword(text); setPassword(text);
}; };
const handleSignupBtnClick = async () => { const handleSignupBtnClick = async (e: FormEvent) => {
e.preventDefault();
if (actionBtnLoadingState.isLoading) { if (actionBtnLoadingState.isLoading) {
return; return;
} }

View File

@ -5,6 +5,7 @@ export interface Filter {
tag?: string; tag?: string;
mineOnly?: boolean; mineOnly?: boolean;
visibility?: Visibility; visibility?: Visibility;
search?: string;
} }
export interface Order { export interface Order {
@ -48,7 +49,7 @@ const useViewStore = create<ViewState>()(
); );
export const getFilteredShortcutList = (shortcutList: Shortcut[], filter: Filter, currentUser: User) => { export const getFilteredShortcutList = (shortcutList: Shortcut[], filter: Filter, currentUser: User) => {
const { tag, mineOnly, visibility } = filter; const { tag, mineOnly, visibility, search } = filter;
const filteredShortcutList = shortcutList.filter((shortcut) => { const filteredShortcutList = shortcutList.filter((shortcut) => {
if (tag) { if (tag) {
if (!shortcut.tags.includes(tag)) { if (!shortcut.tags.includes(tag)) {
@ -65,6 +66,16 @@ export const getFilteredShortcutList = (shortcutList: Shortcut[], filter: Filter
return false; return false;
} }
} }
if (search) {
if (
!shortcut.name.toLowerCase().includes(search.toLowerCase()) &&
!shortcut.description.toLowerCase().includes(search.toLowerCase()) &&
!shortcut.tags.some((tag) => tag.toLowerCase().includes(search.toLowerCase())) &&
!shortcut.link.toLowerCase().includes(search.toLowerCase())
) {
return false;
}
}
return true; return true;
}); });
return filteredShortcutList; return filteredShortcutList;