Compare commits

..

No commits in common. "v0.1.2-beta" and "master" have entirely different histories.

42 changed files with 2511 additions and 77 deletions

View File

@ -1,2 +1,3 @@
node_modules frontend/node_modules
dist dist
frontend/dist

View File

@ -5,7 +5,7 @@ on:
- 'v*' - 'v*'
jobs: jobs:
build and run u-api: build ushare:
runs-on: tencent-sg runs-on: tencent-sg
steps: steps:
- name: prepare enviroment - name: prepare enviroment
@ -17,9 +17,6 @@ jobs:
- name: print operator - name: print operator
run: whoami run: whoami
- name: print work dir and files
run: pwd & ls -alsh .
- name: print tag name - name: print tag name
run: echo "Tag name = ${{ gitea.ref_name }}" run: echo "Tag name = ${{ gitea.ref_name }}"
@ -29,13 +26,29 @@ jobs:
${{ secrets.DOCKER_CONFIG }} ${{ secrets.DOCKER_CONFIG }}
EOF EOF
- name: build by kaniko in docker - name: print work dir and files
run: | run: pwd & ls -alsh .
docker run --rm -v $(pwd):/workspace \
-v $(pwd)/.docker.config.json:/kaniko/.docker/config.json:ro \ - name: build image by docker build
gcr.io/kaniko-project/executor:latest \ run: docker build -t gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }} .
'-d "gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }}"' \
'--single-snapshot' - name: login repository
run: echo ${{ secrets.DOCKER_REPOSITORY_PASSWORD }} | docker login --username loveuer --password-stdin gitea.loveuer.com/loveuer
- name: push image to repository
run: docker push gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }}
# - name: build by kaniko in docker
# run: |
# docker run --rm -v $(pwd):/workspace \
# -v $(pwd)/.docker.config.json:/kaniko/.docker/config.json:ro \
# alpine:latest \
# ls -alsh /workspace
# gcr.io/kaniko-project/executor:latest \
# --dockerfile=/workspace/Dockerfile \
# --context=/workspace \
# --destination=gitea.loveuer.com/loveuer/build/u-api:${{ gitea.ref_name }} \
# --single-snapshot
clean: clean:
if: always() if: always()

View File

@ -25,11 +25,13 @@ COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
COPY --from=backend-builder /app/ushare /usr/local/bin/ushare COPY --from=backend-builder /app/ushare /usr/local/bin/ushare
# 配置 Nginx # 配置 Nginx
RUN rm /etc/nginx/conf.d/default.conf COPY deployment/nginx.conf /etc/nginx/nginx.conf
COPY deployment/nginx.conf /etc/nginx/conf.d COPY deployment/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# 开放端口 # 开放端口
EXPOSE 80 EXPOSE 80
# 启动服务 # 启动服务
CMD sh -c "ushare & nginx -g 'daemon off;'" ENTRYPOINT ["entrypoint.sh"]

35
deployment/entrypoint.sh Normal file
View File

@ -0,0 +1,35 @@
#!/bin/sh
set -e
# 启动后端服务(带参数后台运行)
echo "启动后端服务,参数: $@"
ushare "$@" &
BACKEND_PID=$!
# 定义优雅关闭函数
graceful_shutdown() {
echo "接收到终止信号,开始优雅关闭..."
# 先关闭 Nginx
echo "关闭 Nginx..."
nginx -s quit 2>/dev/null || kill -TERM $NGINX_PID 2>/dev/null
# 关闭后端服务
echo "关闭后端服务 PID $BACKEND_PID..."
kill -TERM $BACKEND_PID 2>/dev/null
# 等待进程终止
wait $BACKEND_PID $NGINX_PID 2>/dev/null
exit 0
}
# 捕获系统信号
trap 'graceful_shutdown' SIGTERM SIGINT
# 启动 Nginx前台运行
echo "启动 Nginx..."
nginx -g "daemon off;" &
NGINX_PID=$!
# 等待所有后台进程
wait $BACKEND_PID $NGINX_PID

View File

@ -1,22 +1,60 @@
server { user root;
listen 80; worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
location / { events {
root /usr/share/nginx/html; worker_connections 1024;
try_files $uri $uri/ /index.html; }
}
location /api { http {
proxy_pass http://localhost:9119; include /etc/nginx/mime.types;
proxy_set_header Host $host; default_type application/octet-stream;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /ushare { log_format main '$remote_addr - $remote_user [$time_local] "$request" '
proxy_pass http://localhost:9119; '$status $body_bytes_sent "$http_referer" '
proxy_set_header Host $host; '"$http_user_agent" "$http_x_forwarded_for"';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://localhost:9119;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/ulocal/ws {
proxy_pass http://localhost:9119;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /ushare {
proxy_pass http://localhost:9119;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_max_temp_file_size 0;
proxy_request_buffering off;
proxy_buffering off;
client_max_body_size 5M;
}
} }
} }

View File

@ -1,5 +1,5 @@
import {useState} from "react"; import {useState} from "react";
import {message} from "../component/message/u-message.tsx"; import {message} from "../hook/message/u-message.tsx";
export interface User { export interface User {
id: number; id: number;

View File

@ -16,7 +16,6 @@ export const useFileUpload = () => {
setProgress(0); setProgress(0);
try { try {
console.log(`[D] api.Upload: upload file = ${file.name}, size = ${file.size}`, file);
const url = `/api/ushare/${file.name}`; const url = `/api/ushare/${file.name}`;
// 1. 初始化上传 // 1. 初始化上传
@ -26,7 +25,7 @@ export const useFileUpload = () => {
}); });
if (!res1.ok) { if (!res1.ok) {
console.log(`[D] upload: put file not ok, status = ${res1.status}, res = ${await res1.text()}`) console.log(`[W] upload: put file not ok, status = ${res1.status}, res = ${await res1.text()}`)
if (res1.status === 401) { if (res1.status === 401) {
window.location.href = "/login?next=/share" window.location.href = "/login?next=/share"
return "" return ""

View File

@ -0,0 +1,137 @@
import React, { useEffect, useRef, ReactNode } from 'react';
import {createUseStyles} from "react-jss";
const useClass = createUseStyles({
dialog: {
border: "none",
borderRadius: "8px",
padding: "2rem 3rem",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
maxWidth: "90%",
width: "500px",
opacity: 0,
transform: "translateY(-20px)",
transition: "opacity 0.3s ease-out, transform 0.3s ease-out",
"&[open]": { opacity: 1, transform: "translateY(0)" },
"&::backdrop": {
background: "rgba(0, 0, 0, 0.5)",
backdropFilter: "blur(2px)"
},
background: "rgba(212,212,212,0.85)",
backdropFilter: "blur(8px)",
},
dialog_content: {
padding: "1.5rem",
display: "flex",
flexDirection: "column"
},
dialog_header: {
fontSize: "1.5rem",
fontWeight: 600,
marginBottom: "1rem",
paddingBottom: "0.5rem",
borderBottom: "1px solid #eee"
},
dialog_body: { marginBottom: "1.5rem" },
dialog_footer: { display: "flex", justifyContent: "flex-end" },
close_button: {
padding: "8px 16px",
background: "#007aff",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "1rem",
"&:hover": { background: "#0062cc" }
},
})
export interface DialogProps {
/** 对话框是否打开 */
open: boolean;
/** 对话框标题 */
title?: string;
/** 对话框内容 */
children: ReactNode;
/** 关闭对话框时的回调 */
onClose: () => void;
/** 自定义样式类名 */
className?: string;
/** 是否显示底部footer关闭按钮 */
footer?: boolean;
}
/**
* 使 HTML dialog
*/
export const Dialog: React.FC<DialogProps> = ({
open,
title,
children,
onClose,
className = '',
footer = true,
}) => {
const classes = useClass();
const dialogRef = useRef<HTMLDivElement>(null);
// ESC 键关闭
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
// 遮罩点击关闭
const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === dialogRef.current) {
onClose();
}
};
if (!open) return null;
return (
<div
ref={dialogRef}
className={className}
style={{
position: 'fixed',
zIndex: 1100,
left: 0,
top: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(2px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={handleMaskClick}
>
<article className={classes.dialog} style={{opacity: 1, transform: 'translateY(0)'}}>
{title && <header className={classes.dialog_header}>{title}</header>}
<div className={classes.dialog_body}>
{children}
</div>
{footer !== false && (
<footer className={classes.dialog_footer}>
<button
className={classes.close_button}
onClick={onClose}
aria-label="关闭对话框"
>
</button>
</footer>
)}
</article>
</div>
);
};

View File

@ -0,0 +1,84 @@
import React, {useEffect} from 'react';
import {createUseStyles} from "react-jss"; // 使用 CSS Modules
const useClass = createUseStyles({
backdrop: {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
maxHeight: '100%',
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
justifyContent: "center",
alignItems: "flex-end",
opacity: 0,
transition: "opacity 0.3s ease-in-out",
pointerEvents: "none",
zIndex: 1000,
overflow: 'hidden',
},
visible: {opacity: 1, pointerEvents: "auto"},
drawer_content: {
background: "white",
borderRadius: "8px 8px 0 0",
transition: "transform 0.3s ease-in-out",
overflow: "auto",
boxShadow: "0 -2px 8px rgba(0, 0, 0, 0.1)"
}
})
export interface DrawerProps {
isOpen: boolean;
close: () => void;
onClose?: () => void;
height?: string;
width?: string;
children?: React.ReactNode;
}
export const Drawer: React.FC<DrawerProps> = ({
isOpen,
close,
onClose,
height = '300px',
width = '100%',
children
}) => {
const classes = useClass();
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
close()
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
}, [isOpen, onClose]);
return (
<div
className={`${classes.backdrop} ${isOpen ? classes.visible : ''}`}
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className={classes.drawer_content}
style={{
height,
width,
transform: isOpen ? 'translateY(0)' : 'translateY(100%)'
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
};

View File

@ -0,0 +1,77 @@
import { useCallback, useRef } from 'react';
export interface Prop {
/** 事件处理函数(可选) */
fn?: (event: MessageEvent) => Promise<void>;
/** 最大重试次数(可选) */
retry?: number;
}
export const useWebsocket = (prop?: Prop) => {
const wsRef = useRef<WebSocket | null>(null);
const retryCountRef = useRef(0);
const reconnectTimerRef = useRef<number>(0);
const currentPropRef = useRef(prop);
// 更新最新 prop
currentPropRef.current = prop;
const connect = useCallback((url: string, connectProp?: Prop) => {
// 合并 prop 优先级connectProp > hook prop
const mergedProp = { ...currentPropRef.current, ...connectProp };
// 清理现有连接
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
clearTimeout(reconnectTimerRef.current);
const createConnection = () => {
const ws = new WebSocket(url);
ws.onopen = () => {
retryCountRef.current = 0;
};
ws.onmessage = (event) => {
mergedProp?.fn?.(event).catch(error => {
console.error('WebSocket message handler error:', error);
});
};
ws.onclose = (event) => {
const maxRetries = mergedProp?.retry ?? 0;
if (!event.wasClean && retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
const retryDelay = Math.pow(2, retryCountRef.current) * 1000;
reconnectTimerRef.current = setTimeout(() => {
createConnection();
}, retryDelay);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
ws.close();
};
wsRef.current = ws;
};
createConnection();
}, []);
const close = useCallback(() => {
wsRef.current?.close();
clearTimeout(reconnectTimerRef.current);
retryCountRef.current = currentPropRef.current?.retry || 0;
}, []);
return {
connect,
close
};
};

View File

@ -0,0 +1,6 @@
export interface Resp<T>{
status: number;
msg: string;
err: string;
data: T;
}

View File

@ -1,19 +1,23 @@
import { StrictMode } from 'react' // import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import {createBrowserRouter, RouterProvider} from "react-router-dom"; import {createBrowserRouter, RouterProvider} from "react-router-dom";
import {Login} from "./page/login.tsx"; import {Login} from "./page/login.tsx";
import {FileSharing} from "./page/share.tsx"; import {FileSharing} from "./page/share/share.tsx";
import {LocalSharing} from "./page/local/local.tsx";
import {TestPage} from "./page/test/test.tsx";
const container = document.getElementById('root') const container = document.getElementById('root')
const root = createRoot(container!) const root = createRoot(container!)
const router = createBrowserRouter([ const router = createBrowserRouter([
{path: "/login", element: <Login />}, {path: "/login", element: <Login />},
{path: "*", element: <FileSharing />}, {path: "/share", element: <FileSharing />},
{path: "/test", element: <TestPage />},
{path: "*", element: <LocalSharing />},
]) ])
root.render( root.render(
<StrictMode> // <StrictMode>
<RouterProvider router={router} /> <RouterProvider router={router} />
</StrictMode>, // </StrictMode>,
) )

View File

@ -0,0 +1,75 @@
import {Bubble, Client} from "./types.ts";
// 生成随机颜色
export const generateColor = () => {
const hue = Math.random() * 360;
return `hsla(${hue},
${Math.random() * 30 + 40}%,
${Math.random() * 10 + 75}%, 0.9)`;
};
// 防碰撞位置生成
export const generateBubbles = (clients: Client[], currentUserId: string): Bubble[] => {
if (!clients) return [];
const BUBBLE_SIZE = 100;
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const bubbles: Bubble[] = [];
let currentRadius = 0;
let angleStep = (2 * Math.PI) / 6; // 初始6个位置
for (let index = 0; index < clients.length; index++) {
let attempt = 0;
let validPosition = false;
if (clients[index].id === currentUserId) {
continue;
}
while (!validPosition && attempt < 100) {
// 螺旋布局算法
const angle = angleStep * (index + attempt);
const radius = currentRadius + (attempt * BUBBLE_SIZE * 0.8);
// 极坐标转笛卡尔坐标
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
// 边界检测
const inBounds = x >= 0 && x <= window.innerWidth - BUBBLE_SIZE &&
y >= 0 && y <= window.innerHeight - BUBBLE_SIZE;
// 碰撞检测
const collision = bubbles.some(pos => {
const distance = Math.sqrt(
Math.pow(pos.x - x, 2) +
Math.pow(pos.y - y, 2)
);
return distance < BUBBLE_SIZE * 1.5;
});
if (inBounds && !collision) {
bubbles.push({
id: clients[index].id,
name: clients[index].name,
x: x,
y: y,
color: generateColor(),
radius: 0,
angle: 0
});
// 动态调整布局参数
currentRadius = Math.max(currentRadius, radius);
angleStep = (2 * Math.PI) / Math.max(6, bubbles.length * 0.7);
validPosition = true;
}
attempt++;
}
}
return bubbles;
};

View File

@ -0,0 +1,163 @@
import React, {useState} from 'react';
import {Dialog} from "../../../component/dialog/dialog.tsx";
import {ReceivedMessage} from "./types.ts";
import {createUseStyles} from "react-jss";
const useStyles = createUseStyles({
root: {
marginBottom: '1rem',
},
sender: {
margin: '0 0 0.5rem 0',
fontSize: '0.9rem',
color: '#666',
},
msgBox: {
background: 'rgba(255,255,255,0.96)',
color: '#222',
padding: '1rem',
borderRadius: '4px',
border: '1px solid #ddd',
maxHeight: '400px',
minHeight: '80px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
downloadLink: {
color: '#007aff',
textDecoration: 'underline',
},
btnRow: {
display: 'flex',
flexDirection: 'row',
gap: '0.5rem',
justifyContent: 'flex-end',
alignItems: 'center',
marginTop: '8px',
},
copySuccess: {
color: '#28a745',
fontSize: '0.9rem',
marginRight: '0.5rem',
animation: 'fadeIn 0.3s ease-in',
},
copyBtn: {
padding: '8px 16px',
background: '#007aff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
transition: 'all 0.2s',
'&:hover': {
background: '#0056b3',
},
},
copyBtnSuccess: {
background: '#28a745',
},
closeBtn: {
padding: '8px 16px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
transition: 'all 0.2s',
},
progressBar: {
width: '100%',
height: 8,
background: '#eee',
borderRadius: 4,
margin: '12px 0',
overflow: 'hidden',
},
progressInner: {
height: '100%',
background: '#4dabf7',
transition: 'width 0.3s',
},
});
interface MessageDialogProps {
open: boolean;
message: ReceivedMessage | null;
onClose: () => void;
}
export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onClose}) => {
const [copySuccess, setCopySuccess] = useState(false);
const classes = useStyles();
const handleCopyMessage = () => {
if (message) {
navigator.clipboard.writeText(message.text || '').then(() => {
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}).catch(() => {
alert('复制失败,请手动复制');
});
}
};
return (
<Dialog
open={open}
title="收到新消息"
onClose={onClose}
footer={false}
>
<div className={classes.root}>
<p className={classes.sender}>
: {message?.sender}
</p>
<div className={classes.msgBox}>
{message?.isFile ? (
<>
<div>📎 : {message.fileName} ({message.fileSize ? (message.fileSize/1024).toFixed(1) : ''} KB)</div>
{message.receiving ? (
<>
<div style={{fontSize: '0.95em', color: '#888'}}>... {Math.round((message.progress||0)*100)}%</div>
<div className={classes.progressBar}>
<div className={classes.progressInner} style={{width: `${Math.round((message.progress||0)*100)}%`}} />
</div>
</>
) : (
<a href={message.fileBlobUrl} download={message.fileName} className={classes.downloadLink}>
</a>
)}
</>
) : (
message?.text
)}
</div>
</div>
<div className={classes.btnRow}>
{copySuccess && (
<span className={classes.copySuccess}>
</span>
)}
{!message?.isFile && (
<button
onClick={handleCopyMessage}
className={copySuccess ? `${classes.copyBtn} ${classes.copyBtnSuccess}` : classes.copyBtn}
>
{copySuccess ? '已复制' : '复制消息'}
</button>
)}
<button
onClick={onClose}
className={classes.closeBtn}
>
</button>
</div>
</Dialog>
);
};

View File

@ -0,0 +1,323 @@
import { WSMessage, ReceivedMessage} from "./types.ts";
import {useLocalStore} from "../../../store/local.ts";
// 文件接收缓存
const fileReceiveCache: Record<string, {chunks: ArrayBuffer[], total: number, received: number, name: string, size: number, sender: string, timestamp: number}> = {};
export const handleFileChunk = (chunk: any, onFileReceived: (msg: import("./types").ReceivedMessage) => void) => {
if (chunk.type === 'file') {
// 文件元信息,初始化缓存
fileReceiveCache[chunk.name + '_' + chunk.timestamp] = {
chunks: new Array(chunk.totalChunks),
total: chunk.totalChunks,
received: 0,
name: chunk.name,
size: chunk.size,
sender: chunk.sender,
timestamp: chunk.timestamp
};
// 首次弹出进度
onFileReceived({
sender: chunk.sender,
timestamp: chunk.timestamp,
fileName: chunk.name,
fileSize: chunk.size,
isFile: true,
progress: 0,
receiving: true
});
} else if (chunk.type === 'file-chunk') {
const key = chunk.name + '_' + chunk.timestamp;
const cache = fileReceiveCache[key];
if (cache) {
// 将 data 数组还原为 ArrayBuffer
const uint8 = new Uint8Array(chunk.data);
cache.chunks[chunk.chunkIndex] = uint8.buffer;
cache.received++;
// 实时回调进度
if (cache.received < cache.total) {
onFileReceived({
sender: cache.sender,
timestamp: cache.timestamp,
fileName: cache.name,
fileSize: cache.size,
isFile: true,
progress: cache.received / cache.total,
receiving: true
});
}
}
} else if (chunk.type === 'file-end') {
const key = chunk.name + '_' + chunk.timestamp;
const cache = fileReceiveCache[key];
if (cache && cache.received === cache.total) {
const blob = new Blob(cache.chunks);
const url = URL.createObjectURL(blob);
onFileReceived({
sender: cache.sender,
timestamp: cache.timestamp,
fileName: cache.name,
fileSize: cache.size,
fileBlobUrl: url,
isFile: true,
progress: 1,
receiving: false
});
delete fileReceiveCache[key];
} else if (cache) {
// 分块未齐全,提示异常
onFileReceived({
sender: cache.sender,
timestamp: cache.timestamp,
fileName: cache.name,
fileSize: cache.size,
isFile: true,
progress: cache.received / cache.total,
receiving: true,
text: '文件分块未齐全,接收失败'
});
}
}
};
export interface RTCHandlerCallbacks {
onChannelOpen: (type: 'sender' | 'receiver') => void;
onMessageReceived: (message: ReceivedMessage) => void;
onChannelClose: () => void;
}
export class RTCHandler {
private rtcRef: React.MutableRefObject<RTCPeerConnection | null>;
private callbacks: RTCHandlerCallbacks;
constructor(rtcRef: React.MutableRefObject<RTCPeerConnection | null>, callbacks: RTCHandlerCallbacks) {
this.rtcRef = rtcRef;
this.callbacks = callbacks;
}
// 更新回调函数的方法
updateCallbacks = (newCallbacks: RTCHandlerCallbacks) => {
this.callbacks = newCallbacks;
};
setupDataChannel = async (ch: RTCDataChannel, type: 'sender' | 'receiver') => {
ch.onopen = () => {
console.log(`[D] 通道已打开!类型: ${type}`);
this.callbacks.onChannelOpen(type);
useLocalStore.getState().setChannel(ch);
};
ch.onmessage = (e) => {
// console.log('[D] Received message:', e.data);
try {
const data = JSON.parse(e.data);
if (data.type === 'message') {
// 处理文本消息
const message: ReceivedMessage = {
text: data.content,
timestamp: Date.now(),
sender: data.sender || '未知用户'
};
this.callbacks.onMessageReceived(message);
} else if (data.type === 'file' || data.type === 'file-chunk' || data.type === 'file-end') {
// 处理文件相关消息
handleFileChunk(data, this.callbacks.onMessageReceived);
}
} catch (error) {
// 如果不是JSON格式当作普通文本处理
const message: ReceivedMessage = {
text: e.data,
timestamp: Date.now(),
sender: '未知用户'
};
this.callbacks.onMessageReceived(message);
}
};
ch.onclose = () => {
console.log('[D] 通道关闭');
this.callbacks.onChannelClose();
useLocalStore.getState().setChannel();
};
};
handleBubbleClick = async (bubbleId: string, currentUserId: string) => {
const current_rtc = this.rtcRef.current;
if (!current_rtc) return;
current_rtc.onnegotiationneeded = async () => {
const offer = await current_rtc.createOffer();
await current_rtc.setLocalDescription(offer);
const data = {
id: bubbleId,
from: currentUserId,
offer: offer,
};
await fetch('/api/ulocal/offer', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify(data)
});
};
current_rtc.onicecandidate = async (e) => {
await fetch('/api/ulocal/candidate', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify({candidate: e.candidate, id: currentUserId})
});
};
const ch = current_rtc.createDataChannel('local', {ordered: true});
await this.setupDataChannel(ch, 'sender');
};
handleWSEvent = async (e: MessageEvent) => {
let current_id: string;
let current_rtc: RTCPeerConnection | null;
const msg = JSON.parse(e.data) as WSMessage;
// console.log('[D] ws event msg =', msg);
switch (msg.type) {
case "enter":
case "leave":
// 这些事件由父组件处理
return;
case "offer":
const offer_data = msg.data as { id: string; from: string; offer: RTCSessionDescriptionInit };
current_id = useLocalStore.getState().id;
if (offer_data.id !== current_id) {
console.warn(`[W] wrong offer id, want = ${current_id}, got = ${offer_data.id}, data =`, offer_data);
return;
}
current_rtc = this.rtcRef.current;
if (!current_rtc) {
console.warn('[W] rtc undefined');
return;
}
await current_rtc.setRemoteDescription(offer_data.offer);
const answer = await current_rtc.createAnswer();
if (!answer) {
console.log('[W] answer undefined');
return;
}
await current_rtc.setLocalDescription(answer);
current_rtc.ondatachannel = (e) => {
this.setupDataChannel(e.channel, 'receiver');
};
await fetch('/api/ulocal/answer', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify({id: offer_data.from, answer: answer})
});
return;
case "answer":
const answer_data = msg.data as { answer: RTCSessionDescriptionInit; id: string };
current_id = useLocalStore.getState().id;
if (answer_data.id !== current_id) {
console.warn(`[W] wrong answer id, want = ${current_id}, got = ${answer_data.id}, data =`, answer_data);
}
current_rtc = this.rtcRef.current;
if (!current_rtc) {
console.warn('[W] rtc undefined');
return;
}
await current_rtc.setRemoteDescription(answer_data.answer);
return;
case "candidate":
const candidate_data = msg.data as { candidate: RTCIceCandidateInit };
current_rtc = this.rtcRef.current;
if (!current_rtc) {
console.warn('[W] rtc undefined');
return;
}
if (!candidate_data.candidate) {
return;
}
await current_rtc.addIceCandidate(candidate_data.candidate);
return;
}
};
sendMessage = (msg: string, files: File[], senderName: string) => {
const ch = useLocalStore.getState().channel;
const CHUNK_SIZE = 64 * 1024; // 64KB
const BUFFERED_AMOUNT_THRESHOLD = 1 * 1024 * 1024; // 1MB
if (ch && ch.readyState === 'open') {
if (msg.trim()) {
// 发送文本消息
const messageData = {
type: 'message',
content: msg,
sender: senderName,
timestamp: Date.now()
};
ch.send(JSON.stringify(messageData));
}
if (files && files.length > 0) {
files.forEach(file => {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const timestamp = Date.now();
// 先发送文件元信息
const fileData = {
type: 'file',
name: file.name,
size: file.size,
sender: senderName,
timestamp,
totalChunks
};
ch.send(JSON.stringify(fileData));
let offset = 0;
let chunkIndex = 0;
const reader = new FileReader();
const sendNextChunk = () => {
if (offset >= file.size) {
// 分块全部发送完毕,发送 file-end
ch.send(JSON.stringify({
type: 'file-end',
name: file.name,
timestamp,
}));
return;
}
if (ch.bufferedAmount > BUFFERED_AMOUNT_THRESHOLD) {
ch.addEventListener('bufferedamountlow', sendNextChunk, { once: true });
return;
}
const slice = file.slice(offset, offset + CHUNK_SIZE);
reader.onload = (e) => {
if (e.target?.result) {
ch.send(JSON.stringify({
type: 'file-chunk',
name: file.name,
timestamp,
chunkIndex,
totalChunks,
data: Array.from(new Uint8Array(e.target.result as ArrayBuffer)),
}));
offset += CHUNK_SIZE;
chunkIndex++;
sendNextChunk();
}
};
reader.readAsArrayBuffer(slice);
};
sendNextChunk();
});
}
}
};
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import {Dialog} from '../../../component/dialog/dialog.tsx';
import {Sender} from './sender.tsx';
interface SendDialogProps {
open: boolean;
onSend: (msg: string, files: File[]) => void;
onClose: () => void;
name: string;
}
export const SendDialog: React.FC<SendDialogProps> = ({open, onSend, onClose, name}) => {
return (
<Dialog open={open} title={`发送消息给${name}`} onClose={onClose}>
<Sender onSend={onSend} />
</Dialog>
);
};

View File

@ -0,0 +1,161 @@
import React, {useRef, useState} from 'react';
import {createUseStyles} from "react-jss";
import { message } from '../../../hook/message/u-message.tsx';
const useClass = createUseStyles({
container: {
width: "100%",
minHeight: "260px",
margin: "0",
border: "1px solid #ddd",
borderRadius: "8px",
overflow: "hidden",
position: "relative",
background: "#fff",
boxSizing: "border-box",
padding: "20px 16px 60px 16px"
},
input_box: {
width: "100%",
minHeight: "180px",
padding: "12px",
boxSizing: "border-box",
border: "none",
resize: "vertical",
outline: "none",
fontFamily: "Arial, sans-serif",
fontSize: "1.1rem",
transition: "padding 0.3s ease",
background: "#f8fafd",
borderRadius: "6px"
},
input_has_files: {
paddingTop: '50px',
},
file_list: {
position: "absolute",
top: "0",
left: "0",
right: "0",
padding: "8px 10px",
background: "rgba(255, 255, 255, 0.95)",
borderBottom: "1px solid #eee",
display: "none",
flexWrap: "wrap",
gap: "6px",
maxHeight: "60px",
overflowY: "auto",
},
list_has_files: {
display: 'flex'
},
"@keyframes slideIn": {
from: {transform: "translateY(-10px)", opacity: 0},
to: {transform: "translateY(0)", opacity: 1}
},
file_item: {
display: "flex",
alignItems: "center",
background: "#f0f6ff",
color: '#1661ab',
border: "1px solid #c2d9ff",
borderRadius: "15px",
padding: "4px 12px",
fontSize: "13px",
animation: "$slideIn 0.2s ease"
},
delete_btn: {
width: "16px",
height: "16px",
border: "none",
background: "#ff6b6b",
color: "white",
borderRadius: "50%",
marginLeft: "8px",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
"&:hover": {background: "#ff5252"},
},
buttons: {
position: "absolute",
bottom: "16px",
right: "16px",
display: "flex",
gap: "12px"
},
action_btn: {
padding: "10px 22px",
background: "#4dabf7",
color: "white",
border: "none",
borderRadius: "20px",
cursor: "pointer",
fontSize: "1rem",
transition: "all 0.2s",
"&:hover": {background: "#339af0", transform: "translateY(-1px)"}
},
})
export interface SenderProps {
onSend: (message: string, files: File[]) => void;
}
export const Sender: React.FC<SenderProps> = ({onSend}) => {
const classes = useClass();
const [inputMessage, setInputMessage] = useState('');
const [files, setFiles] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null)
const handleTextInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInputMessage(e.target.value)
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(e.target.files[0]);
} else {
message.warning('未能选择文件');
}
};
const handleSubmit = () => {
if (!(inputMessage.trim() || files)) {
message.warning('请输入内容或选择文件');
return;
}
try {
onSend(inputMessage, files ? [files] : []);
setInputMessage('');
setFiles(null);
} catch (e) {
message.error('发送失败,请重试');
}
};
return (
<div className={classes.container}>
<textarea className={files?`${classes.input_box} ${classes.input_has_files}`:`${classes.input_box}`} placeholder="开始输入..."
onChange={handleTextInput} value={inputMessage}></textarea>
<div className={files? `${classes.file_list} ${classes.list_has_files}` : `${classes.file_list}`}>
{files && <div className={classes.file_item}>
<span>{files.name}</span>
<button className={classes.delete_btn} onClick={() => {
setFiles(null)
}}>×
</button>
</div>}
</div>
<div className={classes.buttons}>
<input type="file" ref={fileInputRef} hidden onChange={handleFileSelect}/>
<button className={classes.action_btn} onClick={() => {
fileInputRef.current && fileInputRef.current.click()
}}>📁
</button>
<button className={classes.action_btn} onClick={handleSubmit}> </button>
</div>
</div>
);
};

View File

@ -0,0 +1,38 @@
export interface Bubble {
id: string;
name: string;
x: number;
y: number;
color: string;
radius: number;
angle: number;
}
export interface WSMessage {
data: any;
time: number;
type: "register" | "enter" | "leave" | "offer" | "answer" | "candidate"
}
export interface Client {
client_type: 'desktop' | 'mobile' | 'tablet';
app_type: 'web';
ip: number;
name: string;
id: string;
register_at: string;
offer: RTCSessionDescription;
candidate: RTCIceCandidateInit;
}
export interface ReceivedMessage {
text?: string;
timestamp: number;
sender: string;
fileName?: string;
fileSize?: number;
fileBlobUrl?: string;
isFile?: boolean;
progress?: number; // 0-1
receiving?: boolean; // 是否正在接收
}

View File

@ -0,0 +1,51 @@
import React from 'react';
import {createUseStyles} from "react-jss";
import {Bubble} from "./types.ts";
const useClass = createUseStyles({
bubble: {
position: "absolute",
width: "100px",
height: "100px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
cursor: "pointer",
fontFamily: "'Microsoft Yahei', sans-serif",
fontSize: "14px",
color: "rgba(255, 255, 255, 0.9)",
textShadow: "1px 1px 3px rgba(0,0,0,0.3)",
transition: "transform 0.3s ease",
transform: 'translate(-50%, -50%)',
animation: 'emerge 0.5s ease-out forwards,float 6s 0.5s ease-in-out infinite',
background: "radial-gradient(circle at 30% 30%,rgba(255, 255, 255, 0.8) 10%,rgba(255, 255, 255, 0.3) 50%,transparent 100%)",
border: "2px solid rgba(255, 255, 255, 0.5)",
boxShadow: "inset 0 -5px 15px rgba(255,255,255,0.3),0 5px 15px rgba(0,0,0,0.1)",
}
});
interface UserBubbleProps {
bubble: Bubble;
onClick: (bubble: Bubble) => void;
}
export const UserBubble: React.FC<UserBubbleProps> = ({bubble, onClick}) => {
const classes = useClass();
return (
<div
className={classes.bubble}
style={{
left: bubble.x,
top: bubble.y,
backgroundColor: bubble.color,
animationDelay: `${Math.random() * 0.5}s, ${0.5 + Math.random() * 2}s`
}}
onClick={() => onClick(bubble)}
>
{bubble.name}
</div>
);
};

View File

@ -0,0 +1,242 @@
import {CloudBackground} from "../../component/fluid/cloud.tsx";
import {useEffect, useRef, useState, useCallback} from "react";
import {createUseStyles} from "react-jss";
import {useWebsocket} from "../../hook/websocket/u-ws.tsx";
import {Resp} from "../../interface/response.ts";
import {useLocalStore} from "../../store/local.ts";
import {UserBubble} from "./component/user-bubble.tsx";
import {MessageDialog} from "./component/message-dialog.tsx";
import {RTCHandler, RTCHandlerCallbacks} from "./component/rtc-handler.ts";
import {generateBubbles} from "./component/bubble-layout.ts";
import {Client, ReceivedMessage} from "./component/types.ts";
import {SendDialog} from "./component/send-dialog.tsx";
import {message} from "../../hook/message/u-message.tsx";
const useClass = createUseStyles({
'@global': {
'@keyframes emerge': {
'0%': {
transform: 'scale(0) translate(-50%, -50%)',
opacity: 0
},
'80%': {
transform: 'scale(1.1) translate(-50%, -50%)',
opacity: 1
},
'100%': {
transform: 'scale(1) translate(-50%, -50%)',
opacity: 1
}
},
'@keyframes fadeIn': {
'0%': {
opacity: 0,
transform: 'translateY(-10px)'
},
'100%': {
opacity: 1,
transform: 'translateY(0)'
}
}
},
container: {
margin: "0",
height: "100vh",
overflow: "hidden",
position: "relative",
},
title: {
width: '100%',
display: "flex",
justifyContent: "center",
color: '#1661ab',
}
});
export const LocalSharing: React.FC = () => {
const classes = useClass();
const {id, name, set, } = useLocalStore();
const [_rtc, setRTC] = useState<RTCPeerConnection>();
const rtcRef = useRef<RTCPeerConnection | null>(null);
const [clients, setClients] = useState<Client[]>([]);
const {connect, close} = useWebsocket({});
const [open, setOpen] = useState<{ send: boolean; receive: boolean }>({send: false, receive: false});
const [receivedMessage, setReceivedMessage] = useState<ReceivedMessage | null>(null);
const [showMessageDialog, setShowMessageDialog] = useState(false);
const [receivingFile, setReceivingFile] = useState<ReceivedMessage | null>(null);
// RTC处理器的回调函数 - 使用useCallback确保稳定性
const onChannelOpen = useCallback((type: 'sender' | 'receiver') => {
console.log(`[D] Channel opened: ${type}`);
setOpen(val => ({...val, [type]: true}));
}, []);
const onMessageReceived = useCallback((message: ReceivedMessage) => {
if (message.isFile && message.receiving) {
setReceivingFile(message);
} else if (message.isFile && !message.receiving) {
setReceivingFile(null);
setReceivedMessage(message);
setShowMessageDialog(true);
} else {
setReceivedMessage(message);
setShowMessageDialog(true);
}
}, []);
const onChannelClose = useCallback(() => {
console.log('[D] Channel closed');
setOpen({send: false, receive: false});
}, []);
const rtcCallbacks: RTCHandlerCallbacks = {
onChannelOpen,
onMessageReceived,
onChannelClose
};
// 创建RTC处理器实例 - 使用useRef确保实例稳定
const rtcHandlerRef = useRef<RTCHandler | null>(null);
// 更新RTC处理器的回调函数
useEffect(() => {
if (rtcHandlerRef.current) {
rtcHandlerRef.current.updateCallbacks(rtcCallbacks);
}
}, [rtcCallbacks]);
const updateClients = async () => {
setTimeout(async () => {
const res = await fetch(`/api/ulocal/clients`);
const jes = await res.json() as Resp<Client[]>;
setClients(jes.data);
}, 500);
};
const handleWSEvent = async (e: MessageEvent) => {
const msgData = JSON.parse(e.data);
if (msgData.type === "enter" || msgData.type === "leave") {
await updateClients();
return;
}
// 其他RTC相关事件由RTC处理器处理
if (rtcHandlerRef.current) {
try {
await rtcHandlerRef.current.handleWSEvent(e);
} catch (err) {
message.error('通信异常,请刷新页面');
}
} else {
message.error('内部错误:通信模块未初始化');
}
};
const handleBubbleClick = async (bubble: any) => {
setOpen({send: true, receive: false});
if (rtcHandlerRef.current) {
try {
await rtcHandlerRef.current.handleBubbleClick(bubble.id, id);
} catch (e) {
message.error('建立连接失败,请重试');
}
} else {
message.error('内部错误:通信模块未初始化');
console.error('[E] RTC handler is null!');
}
};
const handleSend = (msg: string, files: File[]) => {
if (rtcHandlerRef.current) {
try {
rtcHandlerRef.current.sendMessage(msg, files, name);
} catch (e) {
message.error('发送失败,请重试');
}
} else {
message.error('内部错误:通信模块未初始化');
}
};
const handleCloseMessageDialog = () => {
setShowMessageDialog(false);
setReceivedMessage(null);
};
useEffect(() => {
const fn = async () => {
const response = await fetch('/api/ulocal/register', {method: 'POST'});
const data = ((await response.json()) as Resp<{ id: string; name: string }>).data;
set(data.id, data.name);
connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent});
await updateClients();
const _rtc = new RTCPeerConnection();
rtcRef.current = _rtc;
setRTC(_rtc);
// 在RTC连接创建后立即创建处理器实例
rtcHandlerRef.current = new RTCHandler(rtcRef, rtcCallbacks);
return () => {
close();
if (rtcRef.current) {
rtcRef.current.close();
}
};
};
fn();
}, []);
const bubbles = generateBubbles(clients, id);
useEffect(() => {
if (receivingFile && receivingFile.isFile && !receivingFile.receiving) {
setReceivingFile(null);
setReceivedMessage(receivingFile);
setShowMessageDialog(true);
}
}, [receivingFile]);
return (
<div className={classes.container}>
<CloudBackground/>
<h1 className={classes.title}>
{name}
{/* <span> - {id}</span> */}
</h1>
{bubbles.map(bubble => (
<UserBubble
key={bubble.id}
bubble={bubble}
onClick={handleBubbleClick}
/>
))}
<SendDialog
open={open.send}
onSend={handleSend}
onClose={() => setOpen({send: false, receive: false})}
name={name}
/>
{/* 文件接收进度弹窗 */}
{receivingFile && (
<MessageDialog
open={true}
message={receivingFile}
onClose={() => setReceivingFile(null)}
/>
)}
<MessageDialog
open={showMessageDialog}
message={receivedMessage}
onClose={handleCloseMessageDialog}
/>
</div>
);
};

View File

@ -1,9 +1,9 @@
import {createUseStyles} from "react-jss"; import {createUseStyles} from "react-jss";
import {UButton} from "../../component/button/u-button.tsx"; import {UButton} from "../../../component/button/u-button.tsx";
import React, {useState} from "react"; import React, {useState} from "react";
import {useStore} from "../../store/share.ts"; import {useStore} from "../../../store/share.ts";
import {message} from "../../component/message/u-message.tsx"; import {message} from "../../../hook/message/u-message.tsx";
import {useFileUpload} from "../../api/upload.ts"; import {useFileUpload} from "../../../api/upload.ts";
const useUploadStyle = createUseStyles({ const useUploadStyle = createUseStyles({
container: { container: {
@ -136,7 +136,6 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
} }
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) { function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log('[D] onFileChange: e =', e)
setFile(e.currentTarget.files ? e.currentTarget.files[0] : null) setFile(e.currentTarget.files ? e.currentTarget.files[0] : null)
} }

View File

@ -1,6 +1,6 @@
import {createUseStyles} from "react-jss"; import {createUseStyles} from "react-jss";
import {UButton} from "../../component/button/u-button.tsx"; import {UButton} from "../../../component/button/u-button.tsx";
import {useStore} from "../../store/share.ts"; import {useStore} from "../../../store/share.ts";
const useStyle = createUseStyles({ const useStyle = createUseStyles({
container: { container: {
@ -42,7 +42,6 @@ export const PanelRight = () => {
async function onFetchFile() { async function onFetchFile() {
const url = `/ushare/${code}` const url = `/ushare/${code}`
console.log('[D] onFetchFile: url =', url)
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
document.body.appendChild(link); document.body.appendChild(link);

View File

@ -0,0 +1,18 @@
import {useState} from "react";
import {createUseStyles} from "react-jss";
const useClass = createUseStyles({
container: {}
})
export const TestPage = () => {
const classes = useClass()
const [_open, setOpen] = useState<boolean>(false)
const handleOpen = () => {
setOpen(true)
}
return <div className={classes.container}>
<button onClick={handleOpen}>open</button>
</div>
}

View File

@ -0,0 +1,24 @@
import {create} from 'zustand'
export interface LocalStore {
id: string;
name: string;
channel?: RTCDataChannel;
set: (id: string, name: string) => void;
setChannel: (chan?: RTCDataChannel) => void;
}
export const useLocalStore = create<LocalStore>()((_set, _get) => ({
id: '',
name: '',
set: (id: string, name: string) => {
_set(state => {
return {...state, id: id, name: name};
})
},
setChannel: (ch?: RTCDataChannel) => {
_set(state => {
return {...state, channel: ch}
})
}
}))

View File

@ -10,6 +10,11 @@ export default defineConfig({
target: 'http://127.0.0.1:9119', target: 'http://127.0.0.1:9119',
changeOrigin: true changeOrigin: true
}, },
'/api/ulocal/ws': {
target: 'ws://127.0.0.1:9119',
rewriteWsOrigin: true,
ws: true,
},
'/ushare': { '/ushare': {
target: 'http://127.0.0.1:9119', target: 'http://127.0.0.1:9119',
changeOrigin: true changeOrigin: true

31
go.mod
View File

@ -3,48 +3,65 @@ module github.com/loveuer/ushare
go 1.24.2 go 1.24.2
require ( require (
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jedib0t/go-pretty/v6 v6.6.7
github.com/loveuer/nf v0.3.5 github.com/loveuer/nf v0.3.5
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mileusna/useragent v1.3.5
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/spf13/cast v1.7.1
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
golang.org/x/crypto v0.32.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.0
) )
require ( require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect github.com/cloudflare/circl v1.3.7 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/color v1.17.0 // indirect github.com/fatih/color v1.17.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-git/go-git/v5 v5.12.0 // indirect github.com/go-git/go-git/v5 v5.12.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect github.com/skeema/knownhosts v1.2.2 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/mod v0.17.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.33.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.11.0 // indirect
@ -53,4 +70,8 @@ require (
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
) )

58
go.sum
View File

@ -1,5 +1,7 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
@ -18,6 +20,8 @@ github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxG
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@ -28,6 +32,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@ -38,18 +46,36 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 h1:y3N7Bm7Y9/CtpiVkw/ZWj6lSlDF3F74SfKwfTCer72Q=
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo= github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -70,6 +96,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
@ -80,6 +108,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@ -106,6 +137,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@ -140,8 +172,7 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -157,8 +188,6 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -166,8 +195,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -175,8 +204,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -194,5 +221,20 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=

View File

@ -23,6 +23,16 @@ func Start(ctx context.Context) <-chan struct{} {
app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口 app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口
app.Post("/api/uauth/login", handler.AuthLogin()) app.Post("/api/uauth/login", handler.AuthLogin())
{
api := app.Group("/api/ulocal")
api.Post("/register", handler.LocalRegister())
api.Post("/offer", handler.LocalOffer())
api.Post("/answer", handler.LocalAnswer())
api.Post("/candidate", handler.LocalCandidate())
api.Get("/clients", handler.LocalClients())
api.Get("/ws", handler.LocalWS())
}
ready := make(chan struct{}) ready := make(chan struct{})
ln, err := net.Listen("tcp", opt.Cfg.Address) ln, err := net.Listen("tcp", opt.Cfg.Address)
if err != nil { if err != nil {

View File

@ -104,6 +104,10 @@ func (m *meta) Start(ctx context.Context) {
ticker := time.NewTicker(time.Minute) ticker := time.NewTicker(time.Minute)
m.ctx = ctx m.ctx = ctx
if err := os.MkdirAll(opt.Cfg.DataPath, 0644); err != nil {
log.Fatal("controller.MetaManager.Start: mkdir datapath failed, path = %s, err = %s", opt.Cfg.DataPath, err.Error())
}
// 清理 2 分钟内没有继续上传的 part // 清理 2 分钟内没有继续上传的 part
go func() { go func() {
for { for {
@ -131,14 +135,25 @@ func (m *meta) Start(ctx context.Context) {
// 清理一天前的文件 // 清理一天前的文件
go func() { go func() {
if opt.Cfg.CleanInterval <= 0 {
log.Warn("meta.Clean: no clean interval set, plz clean manual!!!")
return
}
ticker := time.NewTicker(5 * time.Minute) ticker := time.NewTicker(5 * time.Minute)
duration := time.Duration(opt.Cfg.CleanInterval) * time.Hour
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case now := <-ticker.C: case now := <-ticker.C:
//log.Debug("meta.Clean: 开始清理过期文件 = %v", duration)
_ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error { _ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error {
if info == nil {
return nil
}
if info.IsDir() { if info.IsDir() {
return nil return nil
} }
@ -164,12 +179,16 @@ func (m *meta) Start(ctx context.Context) {
code := strings.TrimPrefix(name, ".meta.") code := strings.TrimPrefix(name, ".meta.")
if now.Sub(time.UnixMilli(mi.CreatedAt)) > 24*time.Hour { if now.Sub(time.UnixMilli(mi.CreatedAt)) > duration {
log.Debug("controller.meta: file out of date, code = %s, user_key = %s", code, mi.Uploader) log.Debug("controller.meta: file out of date, code = %s, user_key = %s", code, mi.Uploader)
os.RemoveAll(opt.FilePath(code)) if err = os.RemoveAll(opt.FilePath(code)); err != nil {
os.RemoveAll(path) log.Warn("meta.Clean: remove file failed, file = %s, err = %s", opt.FilePath(code), err.Error())
}
if err = os.RemoveAll(path); err != nil {
log.Warn("meta.Clean: remove file failed, file = %s, err = %s", path, err.Error())
}
m.Lock() m.Lock()
delete(m.m, code) delete(m.m, code)

277
internal/controller/room.go Normal file
View File

@ -0,0 +1,277 @@
// room controller:
// local share websocket room controller
// same remote IP as a
package controller
import (
"context"
"encoding/json"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/ushare/internal/pkg/tool"
"github.com/mileusna/useragent"
"sync"
"time"
)
type RoomClientType string
const (
ClientTypeDesktop RoomClientType = "desktop"
ClientTypeMobile RoomClientType = "mobile"
ClientTypeTablet RoomClientType = "tablet"
)
type RoomAppType string
const (
RoomAppTypeWeb = "web"
)
type RoomMessageType string
const (
RoomMessageTypeEnter RoomMessageType = "enter"
RoomMessageTypeLeave RoomMessageType = "leave"
)
type RoomOffer struct {
SDP string `json:"sdp"`
Type string `json:"type"`
}
type RoomCandidate struct {
Candidate string `json:"candidate"`
SdpMid string `json:"sdpMid"`
SdpMLineIndex int `json:"sdpMLineIndex"`
UsernameFragment string `json:"usernameFragment"`
}
type roomClient struct {
sync.Mutex
controller *roomController
conn *websocket.Conn
ClientType RoomClientType `json:"client_type"`
AppType RoomAppType `json:"app_type"`
IP string `json:"ip"`
Name string `json:"name"`
Id string `json:"id"`
RegisterAt time.Time `json:"register_at"`
msgChan chan any
}
func (rc *roomClient) start(ctx context.Context) {
// start write
go func() {
for {
select {
case <-ctx.Done():
_ = rc.conn.Close()
return
case msg, _ := <-rc.msgChan:
err := rc.conn.WriteJSON(msg)
log.Debug("RoomClient: write json message, IP = %s, Id = %s, Name = %s, err = %v", rc.IP, rc.Id, rc.Name, err)
if err != nil {
log.Error("RoomClient: write json message failed, IP = %s, Id = %s, Name = %s, err = %s", rc.IP, rc.Id, rc.Name, err.Error())
}
}
}
}()
// start read
go func() {
for {
mt, bs, err := rc.conn.ReadMessage()
if err != nil {
log.Error("RoomClient: read message failed, IP = %s, Id = %s, Name = %s, err = %s", rc.IP, rc.Id, rc.Name, err.Error())
rc.controller.Unregister(rc)
return
}
switch mt {
case websocket.PingMessage:
rs, _ := json.Marshal(map[string]any{"type": "pong", "time": time.Now().UnixMilli(), "Id": rc.Id, "Name": rc.Name})
if err := rc.conn.WriteMessage(websocket.PongMessage, rs); err != nil {
log.Error("RoomClient: response ping message failed, IP = %s, Id = %s, Name = %s, err = %s", rc.IP, rc.Id, rc.Name, err.Error())
}
case websocket.CloseMessage:
log.Debug("RoomClient: received close message, unregister IP = %s Id = %s, Name = %s", rc.IP, rc.Id, rc.Name)
rc.controller.Unregister(rc)
return
case websocket.TextMessage:
log.Debug("RoomClient: received text message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs))
case websocket.BinaryMessage:
log.Debug("RoomClient: received bytes message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs))
// todo
//msg := new(model.Message)
//if err = json.Unmarshal(bs, msg); err != nil {
// log.Error("RoomClient: unmarshal message failed, id = %s, name = %s, err = %s", rc.Id, rc.Name, err.Error())
// continue
//}
//
//switch msg.Type {
//case model.WSMessageTypeOffer:
// rc.Lock()
// rc.Offer = msg.Body
// rc.Unlock()
//case model.WSMessageTypeCandidate:
// rc.Lock()
// rc.Candidate = msg.Body
// rc.Unlock()
//}
}
}
}()
}
type roomController struct {
sync.Mutex
ctx context.Context
//rooms map[string]map[string]*roomClient // map[room_id(remote-IP)][Id]
pre map[string]*roomClient
clients map[string]*roomClient
}
var (
RoomController = &roomController{
pre: make(map[string]*roomClient),
clients: make(map[string]*roomClient),
}
)
func (rc *roomController) Start(ctx context.Context) {
rc.ctx = ctx
}
func (rc *roomController) Register(ip, userAgent string) *roomClient {
nrc := &roomClient{
controller: rc,
ClientType: ClientTypeDesktop,
AppType: RoomAppTypeWeb,
IP: ip,
Id: uuid.Must(uuid.NewV7()).String(),
Name: tool.RandomName(),
msgChan: make(chan any, 1),
RegisterAt: time.Now(),
}
ua := useragent.Parse(userAgent)
switch {
case ua.Mobile:
nrc.ClientType = ClientTypeMobile
case ua.Tablet:
nrc.ClientType = ClientTypeTablet
}
rc.Lock()
defer rc.Unlock()
rc.pre[nrc.Id] = nrc
return nrc
}
func (rc *roomController) Enter(conn *websocket.Conn, id string) *roomClient {
log.Debug("controller.room: registry client, id = %s", id)
rc.Lock()
defer rc.Unlock()
nrc, ok := rc.pre[id]
if !ok {
return nil
}
nrc.conn = conn
nrc.start(rc.ctx)
rc.Broadcast(map[string]any{"type": "enter", "time": time.Now().UnixMilli(), "body": nrc})
delete(rc.pre, nrc.Id)
rc.clients[nrc.Id] = nrc
return nrc
}
func (rc *roomController) List() []*roomClient {
clientList := make([]*roomClient, 0)
for _, client := range rc.clients {
clientList = append(clientList, client)
}
return clientList
}
func (rc *roomController) Broadcast(msg any) {
for _, client := range rc.clients {
select {
case client.msgChan <- msg:
case <-time.After(2 * time.Second):
log.Warn("RoomController: broadcast timeout, client Id = %s, IP = %s", client.Id, client.IP)
}
}
}
func (rc *roomController) Unregister(client *roomClient) {
log.Debug("controller.room: unregister client, IP = %s, Id = %s, Name = %s", client.IP, client.Id, client.Name)
rc.Lock()
delete(rc.clients, client.Id)
rc.Unlock()
rc.Broadcast(map[string]any{"type": RoomMessageTypeLeave, "time": time.Now().UnixMilli(), "body": client})
}
func (rc *roomController) Offer(id, from string, offer *RoomOffer) {
if _, ok := rc.clients[id]; !ok {
return
}
rc.clients[id].msgChan <- map[string]any{
"type": "offer",
"time": time.Now().UnixMilli(),
"data": map[string]any{
"id": id,
"from": from,
"offer": offer,
},
}
}
func (rc *roomController) Answer(id string, answer *RoomOffer) {
if _, ok := rc.clients[id]; !ok {
return
}
rc.clients[id].msgChan <- map[string]any{
"type": "answer",
"time": time.Now().UnixMilli(),
"data": map[string]any{
"id": id,
"answer": answer,
},
}
}
func (rc *roomController) Candidate(id string, candidate *RoomCandidate) {
if _, ok := rc.clients[id]; !ok {
return
}
for _, client := range rc.clients {
if client.Id == id {
continue
}
client.msgChan <- map[string]any{
"type": "candidate",
"time": time.Now().UnixMilli(),
"data": map[string]any{
"id": client.Id,
"candidate": candidate,
},
}
}
}

126
internal/handler/local.go Normal file
View File

@ -0,0 +1,126 @@
package handler
import (
"github.com/gorilla/websocket"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
"github.com/loveuer/nf/nft/resp"
"github.com/loveuer/ushare/internal/controller"
"net/http"
)
func LocalRegister() nf.HandlerFunc {
return func(c *nf.Ctx) error {
var (
ip = c.IP(true)
ua = c.Get("User-Agent")
)
client := controller.RoomController.Register(ip, ua)
return resp.Resp200(c, client)
}
}
func LocalClients() nf.HandlerFunc {
return func(c *nf.Ctx) error {
list := controller.RoomController.List()
return resp.Resp200(c, list)
}
}
func LocalWS() nf.HandlerFunc {
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
return func(c *nf.Ctx) error {
id := c.Query("id")
if id == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"error": "id is empty"})
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Error("LocalWS: failed to upgrade websocket connection, err = %s", err.Error())
return err
}
controller.RoomController.Enter(conn, id)
return nil
}
}
func LocalOffer() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Id string `json:"id"`
From string `json:"from"`
Offer *controller.RoomOffer `json:"offer"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
}
controller.RoomController.Offer(req.Id, req.From, req.Offer)
return resp.Resp200(c, req.Offer)
}
}
func LocalAnswer() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Id string `json:"id"`
Answer *controller.RoomOffer `json:"answer"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
}
controller.RoomController.Answer(req.Id, req.Answer)
return resp.Resp200(c, req)
}
}
func LocalCandidate() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Id string `json:"id"`
Candidate *controller.RoomCandidate `json:"candidate"`
}
var (
err error
req = new(Req)
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
}
controller.RoomController.Candidate(req.Id, req.Candidate)
return resp.Resp200(c, req)
}
}

14
internal/model/ws.go Normal file
View File

@ -0,0 +1,14 @@
package model
type WSMessageType string
const (
WSMessageTypeOffer WSMessageType = "offer"
WSMessageTypeCandidate WSMessageType = "candidate"
)
type Message struct {
Type WSMessageType `json:"type"`
Time int64 `json:"time"`
Body any `json:"body"`
}

View File

@ -7,10 +7,11 @@ import (
) )
type config struct { type config struct {
Debug bool Debug bool
Address string Address string
DataPath string DataPath string
Auth string Auth string
CleanInterval int
} }
var ( var (

View File

@ -1,10 +1,11 @@
package db package db
import ( import (
"au99999/internal/opt"
"au99999/pkg/tool"
"context" "context"
"github.com/loveuer/ushare/internal/opt"
"github.com/loveuer/ushare/internal/pkg/tool"
"gorm.io/gorm" "gorm.io/gorm"
) )

59
internal/pkg/tool/ip.go Normal file
View File

@ -0,0 +1,59 @@
package tool
import (
"net"
)
var (
privateIPv4Blocks []*net.IPNet
privateIPv6Blocks []*net.IPNet
)
func init() {
// IPv4私有地址段
for _, cidr := range []string{
"10.0.0.0/8", // A类私有地址
"172.16.0.0/12", // B类私有地址
"192.168.0.0/16", // C类私有地址
"169.254.0.0/16", // 链路本地地址
"127.0.0.0/8", // 环回地址
} {
_, block, _ := net.ParseCIDR(cidr)
privateIPv4Blocks = append(privateIPv4Blocks, block)
}
// IPv6私有地址段
for _, cidr := range []string{
"fc00::/7", // 唯一本地地址
"fe80::/10", // 链路本地地址
"::1/128", // 环回地址
} {
_, block, _ := net.ParseCIDR(cidr)
privateIPv6Blocks = append(privateIPv6Blocks, block)
}
}
func IsPrivateIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
// 处理IPv4和IPv4映射的IPv6地址
if ip4 := ip.To4(); ip4 != nil {
for _, block := range privateIPv4Blocks {
if block.Contains(ip4) {
return true
}
}
return false
}
// 处理IPv6地址
for _, block := range privateIPv6Blocks {
if block.Contains(ip) {
return true
}
}
return false
}

View File

@ -3,14 +3,31 @@ package tool
import ( import (
"crypto/rand" "crypto/rand"
"math/big" "math/big"
mrand "math/rand"
) )
var ( var (
letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
letterNum = []byte("0123456789") letterNum = []byte("0123456789")
letterLow = []byte("abcdefghijklmnopqrstuvwxyz") letterLow = []byte("abcdefghijklmnopqrstuvwxyz")
letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ") letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
letterSyb = []byte("!@#$%^&*()_+-=") letterSyb = []byte("!@#$%^&*()_+-=")
adjectives = []string{
"开心的", "灿烂的", "温暖的", "阳光的", "活泼的",
"聪明的", "优雅的", "幸运的", "甜蜜的", "勇敢的",
"宁静的", "热情的", "温柔的", "幽默的", "坚强的",
"迷人的", "神奇的", "快乐的", "健康的", "自由的",
"梦幻的", "勤劳的", "真诚的", "浪漫的", "自信的",
}
plants = []string{
"苹果", "香蕉", "橘子", "葡萄", "草莓",
"西瓜", "樱桃", "菠萝", "柠檬", "蜜桃",
"蓝莓", "芒果", "石榴", "甜瓜", "雪梨",
"番茄", "南瓜", "土豆", "青椒", "洋葱",
"黄瓜", "萝卜", "豌豆", "玉米", "蘑菇",
"菠菜", "茄子", "芹菜", "莲藕", "西兰花",
}
) )
func RandomInt(max int64) int64 { func RandomInt(max int64) int64 {
@ -52,3 +69,7 @@ func RandomPassword(length int, withSymbol bool) string {
} }
return string(result) return string(result)
} }
func RandomName() string {
return adjectives[mrand.Intn(len(adjectives))] + plants[mrand.Intn(len(plants))]
}

View File

@ -7,6 +7,7 @@ import (
"github.com/loveuer/ushare/internal/api" "github.com/loveuer/ushare/internal/api"
"github.com/loveuer/ushare/internal/controller" "github.com/loveuer/ushare/internal/controller"
"github.com/loveuer/ushare/internal/opt" "github.com/loveuer/ushare/internal/opt"
"github.com/loveuer/ushare/internal/pkg/tool"
"os/signal" "os/signal"
"syscall" "syscall"
) )
@ -16,11 +17,12 @@ func init() {
flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:9119", "") flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:9119", "")
flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "") flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "")
flag.StringVar(&opt.Cfg.Auth, "auth", "", "auth required(admin, password)") flag.StringVar(&opt.Cfg.Auth, "auth", "", "auth required(admin, password)")
flag.IntVar(&opt.Cfg.CleanInterval, "clean", 24, "清理文件的周期, 单位: 小时, 0 则表示不自动清理")
flag.Parse() flag.Parse()
if opt.Cfg.Debug { if opt.Cfg.Debug {
log.SetLogLevel(log.LogLevelDebug) log.SetLogLevel(log.LogLevelDebug)
log.Debug("start server with debug mode") tool.TablePrinter(opt.Cfg)
} }
} }
@ -31,6 +33,7 @@ func main() {
opt.Init(ctx) opt.Init(ctx)
controller.UserManager.Start(ctx) controller.UserManager.Start(ctx)
controller.MetaManager.Start(ctx) controller.MetaManager.Start(ctx)
controller.RoomController.Start(ctx)
api.Start(ctx) api.Start(ctx)
<-ctx.Done() <-ctx.Done()

149
page/bubble.html Normal file
View File

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>柔和气泡</title>
<style>
body {
margin: 0;
height: 100vh;
background: linear-gradient(45deg, #e6e9f0, #eef1f5);
overflow: hidden;
position: relative;
}
.bubble {
position: absolute;
width: 100px;
height: 100px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
cursor: pointer;
font-family: 'Microsoft Yahei', sans-serif;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
text-shadow: 1px 1px 3px rgba(0,0,0,0.3);
transition: transform 0.3s ease;
animation: float 6s ease-in-out infinite;
/* 泡泡细节 */
background: radial-gradient(circle at 30% 30%,
rgba(255, 255, 255, 0.8) 10%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%);
border: 2px solid rgba(255, 255, 255, 0.5);
box-shadow: inset 0 -5px 15px rgba(255,255,255,0.3),
0 5px 15px rgba(0,0,0,0.1);
}
/* 高光效果 */
.bubble::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.7);
border-radius: 50%;
top: 15px;
right: 15px;
filter: blur(2px);
}
.bubble:hover {
transform: scale(1.05) rotate(3deg);
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0deg); }
33% { transform: translateY(-20px) rotate(3deg); }
66% { transform: translateY(10px) rotate(-3deg); }
}
</style>
</head>
<body>
<script>
const prefixes = ['宁静', '温暖', '甜蜜', '柔和', '灿烂', '神秘', '清爽'];
const suffixes = ['梦境', '时光', '旋律', '花园', '云端', '海洋', '晨露'];
const existingPositions = [];
const BUBBLE_SIZE = 100;
// 检测碰撞
function checkCollision(x, y) {
return existingPositions.some(pos => {
const distance = Math.sqrt(
Math.pow(x - pos.x, 2) +
Math.pow(y - pos.y, 2)
);
return distance < BUBBLE_SIZE * 1.2;
});
}
function createBubble() {
let x, y;
let attempts = 0;
// 生成非重叠位置
do {
x = Math.random() * (window.innerWidth - BUBBLE_SIZE);
y = Math.random() * (window.innerHeight - BUBBLE_SIZE);
attempts++;
} while (checkCollision(x, y) && attempts < 100);
if (attempts >= 100) return;
existingPositions.push({x, y});
const bubble = document.createElement('div');
bubble.className = 'bubble';
// 随机颜色
const hue = Math.random() * 360;
bubble.style.backgroundColor = `hsla(${hue},
${Math.random() * 30 + 40}%,
${Math.random() * 10 + 75}%, 0.9)`;
// 随机名称
bubble.textContent = `${prefixes[Math.random()*prefixes.length|0]}的${suffixes[Math.random()*suffixes.length|0]}`;
// 位置和动画
bubble.style.left = x + 'px';
bubble.style.top = y + 'px';
bubble.style.animationDelay = Math.random() * 2 + 's';
// 点击效果
bubble.addEventListener('click', () => {
bubble.style.transition = 'transform 0.5s, opacity 0.5s';
bubble.style.transform = 'scale(1.5)';
bubble.style.opacity = '0';
setTimeout(() => {
bubble.remove();
existingPositions.splice(existingPositions.findIndex(
pos => pos.x === x && pos.y === y), 1);
createBubble();
}, 500);
});
document.body.appendChild(bubble);
}
// 初始化15个气泡
for (let i = 0; i < 15; i++) createBubble();
// 自动补充气泡
setInterval(() => {
if (document.querySelectorAll('.bubble').length < 20) {
createBubble();
}
}, 3000);
// 窗口大小变化时重置
window.addEventListener('resize', () => {
existingPositions.length = 0;
document.querySelectorAll('.bubble').forEach(bubble => bubble.remove());
for (let i = 0; i < 15; i++) createBubble();
});
</script>
</body>
</html>

179
page/sender.html Normal file
View File

@ -0,0 +1,179 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>智能文本区域</title>
<style>
.container {
width: 400px;
height: 300px;
margin: 20px;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.input-box {
width: 100%;
height: 100%;
padding: 10px;
box-sizing: border-box;
border: none;
resize: none;
outline: none;
font-family: Arial, sans-serif;
transition: padding 0.3s ease;
}
.input-box.has-files {
padding-top: 50px;
}
.file-list {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.95);
border-bottom: 1px solid #eee;
display: none;
flex-wrap: wrap;
gap: 6px;
max-height: 60px;
overflow-y: auto;
}
.file-list.has-files {
display: flex;
}
.file-item {
display: flex;
align-items: center;
background: #f0f6ff;
border: 1px solid #c2d9ff;
border-radius: 15px;
padding: 4px 12px;
font-size: 13px;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.delete-btn {
width: 16px;
height: 16px;
border: none;
background: #ff6b6b;
color: white;
border-radius: 50%;
margin-left: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.delete-btn:hover {
background: #ff5252;
}
.buttons {
position: absolute;
bottom: 10px;
right: 10px;
display: flex;
gap: 8px;
}
.action-btn {
padding: 8px 16px;
background: #4dabf7;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: #339af0;
transform: translateY(-1px);
}
</style>
</head>
<body>
<div class="container">
<textarea class="input-box" placeholder="开始输入..."></textarea>
<div class="file-list" id="fileList"></div>
<div class="buttons">
<input type="file" id="fileInput" hidden multiple>
<button class="action-btn" onclick="document.getElementById('fileInput').click()">📁 选择文件</button>
<button class="action-btn" onclick="send()">✈️ 发送</button>
</div>
</div>
<script>
const inputBox = document.querySelector('.input-box');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
// 文件选择事件
fileInput.addEventListener('change', function() {
updateFileList();
adjustInputPadding();
});
// 动态调整输入框间距
function adjustInputPadding() {
inputBox.classList.toggle('has-files', fileInput.files.length > 0);
fileList.classList.toggle('has-files')
}
// 更新文件列表显示
function updateFileList() {
fileList.innerHTML = '';
Array.from(fileInput.files).forEach((file, index) => {
const item = document.createElement('div');
item.className = 'file-item';
item.innerHTML = `
<span>${file.name}</span>
<button class="delete-btn" onclick="removeFile(${index})">×</button>
`;
fileList.appendChild(item);
});
}
// 删除单个文件
function removeFile(index) {
const dt = new DataTransfer();
Array.from(fileInput.files).forEach((file, i) => {
if (i !== index) dt.items.add(file);
});
fileInput.files = dt.files;
updateFileList();
adjustInputPadding();
}
// 发送功能
function send() {
const text = inputBox.value;
const files = fileInput.files;
// 清空内容
inputBox.value = '';
fileInput.value = '';
fileList.innerHTML = '';
adjustInputPadding();
}
</script>
</body>
</html>