├── docs
└── demo1.gif
├── public
├── favicon.ico
├── logo100.png
├── robots.txt
├── manifest.json
└── index.html
├── src
├── index.css
├── components
│ ├── Body.js
│ ├── Button.js
│ ├── Error.js
│ ├── Header.js
│ ├── Footer.js
│ ├── Search.js
│ └── Entity.js
├── services
│ └── entity.js
├── store.js
├── index.js
├── App.js
└── reducers
│ ├── errorReducer.js
│ └── entityReducer.js
├── .env.example
├── Jenkinsfile
├── server.rest
├── tailwind.config.js
├── Dockerfile
├── .github
└── workflows
│ └── fly.yml
├── .gitignore
├── fly.toml
├── License
├── package.json
├── README.md
└── server.js
/docs/demo1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onee-io/short-url/HEAD/docs/demo1.gif
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onee-io/short-url/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onee-io/short-url/HEAD/public/logo100.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | background-color: #FFDE03;
7 | }
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | REDIS_HOST=YOUR_REDIS_HOST
2 | REDIS_PORT=YOUR_REDIS_PORT
3 | REDIS_USERNAME=YOURREDIS_USERNAME
4 | REDIS_PASSWORD=YOUR_REDIS_PASSWORD
--------------------------------------------------------------------------------
/Jenkinsfile:
--------------------------------------------------------------------------------
1 | pipeline {
2 | agent any
3 | stages {
4 | stage('build') {
5 | steps {
6 | sh 'node build'
7 | }
8 | }
9 |
10 | }
11 | }
--------------------------------------------------------------------------------
/server.rest:
--------------------------------------------------------------------------------
1 | GET http://127.0.0.1:3001/5
2 |
3 | ###
4 |
5 | POST http://127.0.0.1:3001
6 | Content-Type: application/json
7 |
8 | {
9 | "url": "https://www.onee.io"
10 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./src/**/*.{js,jsx,ts,tsx}",
5 | ],
6 | theme: {
7 | extend: {},
8 | },
9 | plugins: [],
10 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine3.16
2 |
3 | WORKDIR /app
4 |
5 | COPY package*.json ./
6 |
7 | RUN npm install
8 |
9 | COPY . .
10 |
11 | RUN npm run build
12 |
13 | EXPOSE 3001
14 |
15 | ENTRYPOINT ["npm", "run"]
16 |
17 | CMD ["server"]
--------------------------------------------------------------------------------
/src/components/Body.js:
--------------------------------------------------------------------------------
1 | import Entity from "./Entity";
2 | import Search from "./Search";
3 |
4 | const Body = () => {
5 | return (
6 |
7 |
8 |
9 |
10 | );
11 | }
12 |
13 | export default Body;
--------------------------------------------------------------------------------
/src/services/entity.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | /**
4 | * 生成短链接
5 | * @param {string} url 原始链接
6 | * @returns 短链接
7 | */
8 | const shortUrl = async url => {
9 | const res = await axios.post('/url', { url });
10 | return res.data;
11 | }
12 |
13 | export default { shortUrl };
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import entityReducer from './reducers/entityReducer';
3 | import errorReducer from './reducers/errorReducer';
4 |
5 | const store = configureStore({
6 | reducer: {
7 | entity: entityReducer,
8 | error: errorReducer
9 | }
10 | });
11 |
12 | export default store;
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { Provider } from 'react-redux';
4 | import App from './App';
5 | import './index.css';
6 | import store from './store';
7 |
8 | ReactDOM.createRoot(document.getElementById('root')).render(
9 |
10 |
11 |
12 | )
--------------------------------------------------------------------------------
/src/components/Button.js:
--------------------------------------------------------------------------------
1 | const Button = ({ label, onClick }) => {
2 | return (
3 |
9 | )
10 | }
11 |
12 | export default Button;
--------------------------------------------------------------------------------
/src/components/Error.js:
--------------------------------------------------------------------------------
1 | import { useSelector } from "react-redux";
2 |
3 | const Error = () => {
4 | const error = useSelector(state => state.error);
5 | if (!error) {
6 | return null;
7 | }
8 | return (
9 |
10 | {error}
11 |
12 | );
13 | }
14 |
15 | export default Error;
--------------------------------------------------------------------------------
/src/components/Header.js:
--------------------------------------------------------------------------------
1 | const Header = () => {
2 | return (
3 |
4 |
缩啦短链接
5 |
简单易用的短链接生成工具,链接永久有效!
6 |
7 | );
8 | }
9 |
10 | export default Header;
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import Body from "./components/Body";
2 | import Footer from "./components/Footer";
3 | import Header from "./components/Header";
4 |
5 | const App = () => (
6 |
11 |
12 |
13 | )
14 |
15 | export default App;
--------------------------------------------------------------------------------
/.github/workflows/fly.yml:
--------------------------------------------------------------------------------
1 | name: Fly Deploy
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | env:
9 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
10 |
11 | jobs:
12 | deploy:
13 | name: Deploy app
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v3
17 | - uses: superfly/flyctl-actions/setup-flyctl@master
18 | - run: flyctl deploy --remote-only
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .env
--------------------------------------------------------------------------------
/src/reducers/errorReducer.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | const slice = createSlice({
4 | name: 'error',
5 | initialState: '',
6 | reducers: {
7 | setError(state, action) {
8 | return action.payload;
9 | },
10 | clearError(state, action) {
11 | return '';
12 | }
13 | }
14 | });
15 |
16 | export const { setError, clearError } = slice.actions;
17 |
18 | export default slice.reducer;
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "缩啦",
3 | "name": "缩啦短链接|简单易用的短链接生成工具,链接永久有效!",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo100.png",
12 | "type": "image/png",
13 | "sizes": "100x100"
14 | }
15 | ],
16 | "start_url": ".",
17 | "display": "standalone",
18 | "theme_color": "#000000",
19 | "background_color": "#ffffff"
20 | }
21 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml file generated for onee-short-url on 2023-02-16T17:08:37+08:00
2 |
3 | app = "onee-short-url"
4 | kill_signal = "SIGINT"
5 | kill_timeout = 5
6 | processes = []
7 |
8 | [env]
9 |
10 | [experimental]
11 | auto_rollback = true
12 |
13 | [[services]]
14 | http_checks = []
15 | internal_port = 3001
16 | processes = ["app"]
17 | protocol = "tcp"
18 | script_checks = []
19 | [services.concurrency]
20 | hard_limit = 25
21 | soft_limit = 20
22 | type = "connections"
23 |
24 | [[services.ports]]
25 | force_https = true
26 | handlers = ["http"]
27 | port = 80
28 |
29 | [[services.ports]]
30 | handlers = ["tls", "http"]
31 | port = 443
32 |
33 | [[services.tcp_checks]]
34 | grace_period = "1s"
35 | interval = "15s"
36 | restart_limit = 0
37 | timeout = "2s"
38 |
--------------------------------------------------------------------------------
/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | const Footer = () => {
2 | return (
3 |
4 | 本站由
5 |
8 |
onee
9 | 构建|
10 |
源码
11 | |
12 |
联系站长
13 |
14 | );
15 | }
16 |
17 | export default Footer;
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
19 | 缩啦
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/License:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 onee
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/src/reducers/entityReducer.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import QRCode from 'qrcode';
3 | import entityService from '../services/entity';
4 | import copy from 'copy-to-clipboard';
5 | import { setError } from './errorReducer';
6 |
7 | const slice = createSlice({
8 | name: 'entity',
9 | initialState: null,
10 | reducers: {
11 | setEntity(state, action) {
12 | return action.payload;
13 | },
14 | clearEntity(state, action) {
15 | return null;
16 | }
17 | }
18 | });
19 |
20 | export const { setEntity, clearEntity } = slice.actions;
21 |
22 | export const generateShortUrl = originUrl => {
23 | return async dispatch => {
24 | try {
25 | const res = await entityService.shortUrl(originUrl);
26 | const shortUrl = `${window.location.origin}/${res.code}`;
27 | const qrcode = await QRCode.toDataURL(shortUrl, { errorCorrectionLevel: 'H' });
28 | dispatch(setEntity({
29 | originUrl,
30 | shortUrl,
31 | qrcode,
32 | code: res.code
33 | }));
34 | copy(shortUrl);
35 | } catch (error) {
36 | dispatch(setError(error.message));
37 | }
38 | }
39 | }
40 |
41 | export default slice.reducer;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "short-url",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@reduxjs/toolkit": "^1.9.2",
7 | "@testing-library/jest-dom": "^5.16.5",
8 | "@testing-library/react": "^13.4.0",
9 | "@testing-library/user-event": "^13.5.0",
10 | "axios": "^1.3.3",
11 | "copy-to-clipboard": "^3.3.3",
12 | "dotenv": "^16.0.3",
13 | "express": "^4.18.2",
14 | "file-saver": "^2.0.5",
15 | "morgan": "^1.10.0",
16 | "qrcode": "^1.5.1",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "react-redux": "^8.0.5",
20 | "react-scripts": "5.0.1",
21 | "redis": "^4.6.4",
22 | "web-vitals": "^2.1.4"
23 | },
24 | "scripts": {
25 | "start": "react-scripts start",
26 | "build": "react-scripts build",
27 | "test": "react-scripts test",
28 | "eject": "react-scripts eject",
29 | "server": "node server.js",
30 | "dev": "nodemon server.js"
31 | },
32 | "eslintConfig": {
33 | "extends": [
34 | "react-app",
35 | "react-app/jest"
36 | ]
37 | },
38 | "browserslist": {
39 | "production": [
40 | ">0.2%",
41 | "not dead",
42 | "not op_mini all"
43 | ],
44 | "development": [
45 | "last 1 chrome version",
46 | "last 1 firefox version",
47 | "last 1 safari version"
48 | ]
49 | },
50 | "devDependencies": {
51 | "nodemon": "^2.0.20",
52 | "tailwindcss": "^3.2.6"
53 | },
54 | "proxy": "http://localhost:3001"
55 | }
56 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Short URL
2 |
3 | 将长链接转为短链接的小工具,完全开源、免费、无需登录,可私有化部署,可配合免费的无服务器云平台使用,短链接可永久有效。
4 |
5 | 体验地址 👉 [缩啦 —— 简单易用的短链接生成工具,链接永久有效!](https://3ii.cc)
6 |
7 | 服务托管在 [fly.io](https://fly.io) 平台,此平台可通过 Docker 部署任意类型的应用,前三个应用免费,推荐使用。
8 |
9 | ## 效果演示
10 |
11 | 
12 |
13 | ## 环境依赖
14 |
15 | - NodeJS 16+
16 | - Redis 5+
17 |
18 | ## 部署步骤
19 |
20 | ### 前置操作
21 |
22 | 1. 执行 `npm install` 安装依赖包;
23 |
24 | 2. 将 `.env.example` 文件重命名为 `.env`,并按你的 Redis 实际情况填写好配置信息,说明如下:
25 |
26 | | 配置 | 默认值 | 说明 |
27 | | -------------- | --------- | --------------------------- |
28 | | REDIS_HOST | 127.0.0.1 | Redis 服务 IP 地址,支持 IPv6 |
29 | | REDIS_PORT | 6379 | Redis 服务端口 |
30 | | REDIS_USERNAME | | 用户名,没有留空即可 |
31 | | REDIS_PASSWORD | | 密码,没有留空即可 |
32 |
33 | ### 方式一:前后端分离部署
34 |
35 | 1. 执行 `npm run server` 启动后端服务,占用端口 3001;
36 |
37 | 2. 执行 `npm start` 启动前端服务,占用端口 3000;
38 |
39 | 3. 访问 [http://localhost:3000](http://localhost:3000) 即可使用;
40 |
41 | ### 方式二:通过 express 代理静态资源部署(推荐)
42 |
43 | 1. 执行 `npm run build` 将前端编译为静态文件(生成的 build 目录不要删除);
44 |
45 | 2. 执行 `npm run server` 启动服务;
46 |
47 | 3. 访问 [http://localhost:3001](http://localhost:3001) 即可使用;
48 |
49 | ### 方式三:通过 Docker 部署(推荐)
50 |
51 | 1. 执行 `docker build -t short-url .` 打包镜像;
52 |
53 | 2. 执行 `docker run -d -p3001:3001 short-url` 启动容器;
54 |
55 | 3. 访问 [http://localhost:3001](http://localhost:3001) 即可使用;
56 |
57 |
--------------------------------------------------------------------------------
/src/components/Search.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useDispatch } from "react-redux";
3 | import { clearEntity, generateShortUrl } from "../reducers/entityReducer";
4 | import { clearError, setError } from "../reducers/errorReducer";
5 | import Button from "./Button";
6 | import Error from "./Error";
7 |
8 | const Search = () => {
9 | const dispatch = useDispatch();
10 | const [url, setUrl] = useState('');
11 | // 处理按键事件
12 | const handleClick = () => {
13 | dispatch(clearError());
14 | dispatch(clearEntity());
15 | if (!/^((https|http)?:\/\/)[^\s]+/.test(encodeURI(url))) {
16 | dispatch(setError('URL 格式有误,请输入 http:// 或 https:// 开头的网址'));
17 | } else {
18 | dispatch(generateShortUrl(url));
19 | setUrl('');
20 | }
21 | }
22 | // 监听回车事件
23 | const handleKeyup = (event) => {
24 | if (event.keyCode === 13) {
25 | handleClick();
26 | }
27 | }
28 | return (
29 |
30 |
31 | setUrl(target.value)}
36 | onKeyUp={handleKeyup}
37 | />
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
46 | export default Search;
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 | const express = require('express');
3 | const app = express();
4 |
5 | const morgan = require('morgan');
6 | morgan.token('body', (req) => JSON.stringify(req.body));
7 |
8 | const Redis = require('redis');
9 | const redis = Redis.createClient({
10 | socket: {
11 | host: process.env.REDIS_HOST || '127.0.0.1',
12 | port: process.env.REDIS_PORT || 6379
13 | },
14 | username: process.env.REDIS_USERNAME || null,
15 | password: process.env.REDIS_PASSWORD || null,
16 | pingInterval: 5 * 60 * 1000,
17 | });
18 | redis.on('error', err => console.error(err, 'Redis error'));
19 | redis.on('connect', () => console.log('Redis is connect'));
20 | redis.on('reconnecting', () => console.log('Redis is reconnecting'));
21 | redis.on('ready', () => console.log('Redis is ready'));
22 | const redisKey = {
23 | code: 'short-url:code',
24 | map: 'short-url:map'
25 | };
26 |
27 | const _alphabet = 'GS2w4R6789IbcdHEXhijWZAzopTrxPNq3sLMJalBVyQeDmY0nugtF5Uv1fkOCK';
28 | const _base = _alphabet.length;
29 | const encode = (id) => {
30 | let code = '';
31 | while (id > 0) {
32 | code = _alphabet.charAt(id % _base) + code;
33 | id = Math.floor(id / _base);
34 | }
35 | return code;
36 | };
37 |
38 | app.use(express.static('build'));
39 | app.use(express.json());
40 | app.use(morgan(':method :url :status :res[content-length] - :response-time ms :body'));
41 |
42 | app.get('/:code', async (request, response) => {
43 | const code = request.params.code;
44 | const originUrl = await redis.hGet(redisKey.map, code);
45 | if (!originUrl) {
46 | return response.status(404).json({ error: 'Unknown URL' }).end();
47 | }
48 | response.redirect(originUrl);
49 | });
50 |
51 | app.post('/url', async (request, response) => {
52 | const encodedUrl = encodeURI(request.body.url);
53 | if (!/^((https|http)?:\/\/)[^\s]+/.test(encodedUrl)) {
54 | return response.status(400).json({ error: 'Incorrect URL format' }).end();
55 | }
56 | const id = await redis.incrBy(redisKey.code, 1);
57 | const code = encode(id);
58 | await redis.hSet(redisKey.map, code, encodedUrl);
59 | response.json({ url: encodedUrl, code });
60 | });
61 |
62 | const PORT = 3001;
63 | redis.connect().then(() => {
64 | app.listen(PORT, () => {
65 | console.log(`Server running on port ${PORT}`);
66 | });
67 | });
--------------------------------------------------------------------------------
/src/components/Entity.js:
--------------------------------------------------------------------------------
1 | import copy from 'copy-to-clipboard';
2 | import { saveAs } from 'file-saver';
3 | import { useState } from 'react';
4 | import { useSelector } from 'react-redux';
5 | import Button from './Button';
6 |
7 | const Entity = () => {
8 | const [showCopyed, setShowCopyed] = useState(false);
9 | const entity = useSelector(state => state.entity);
10 | if (!entity) {
11 | return null;
12 | }
13 | // 下载二维码事件
14 | const handleDownload = async () => {
15 | const res = await fetch(entity.qrcode);
16 | const blob = await res.blob();
17 | saveAs(blob, `QRCode_${entity.code}.png`);
18 | }
19 | // 复制短链接事件
20 | const handleCopy = () => {
21 | copy(entity.shortUrl);
22 | setShowCopyed(true);
23 | setTimeout(() => setShowCopyed(false), 3000);
24 | }
25 | return (
26 |
27 |

28 |
29 |
30 | 短链接:{entity.shortUrl}
31 |
32 |
33 | 原链接:{entity.originUrl}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | {showCopyed &&
43 |
44 |
47 | 已复制
48 |
49 | }
50 |
51 |
52 |
53 | );
54 | }
55 |
56 | export default Entity;
--------------------------------------------------------------------------------