├── 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 |
7 |
8 |
9 | 10 |
11 |
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 | 6 | 7 | 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 | ![Demo](https://cdn.jsdelivr.net/gh/onee-io/short-url/docs/demo1.gif) 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 |
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 | QRCode 28 |
29 |

30 | 短链接:{entity.shortUrl} 31 |

32 |

33 | 原链接:{entity.originUrl} 34 |

35 |
36 |
37 |
39 |
40 |
42 | {showCopyed && 43 |
44 | 45 | 46 | 47 | 已复制 48 |
49 | } 50 |
51 |
52 |
53 | ); 54 | } 55 | 56 | export default Entity; --------------------------------------------------------------------------------