@@ -54,6 +54,10 @@ curl -L \
|
||||
|
||||
token 无效、已禁用、已过期或使用次数耗尽:返回 403。
|
||||
|
||||
## Web 首页
|
||||
|
||||
访问根路径 `/` 会返回一个简单的项目介绍页,包含:GitHub 链接输入框、Hugs-Proxy Token 输入框、以及“下载”按钮(表单 POST 到 `/download`)。
|
||||
|
||||
## tokenctl 用法
|
||||
|
||||
```text
|
||||
@@ -62,6 +66,7 @@ tokenctl issue-token --user <username> [--expires <7d|30d|RFC3339>] [--uses <n>]
|
||||
tokenctl disable-token --token <token> [--db <path>]
|
||||
tokenctl list-users [--db <path>]
|
||||
tokenctl list-tokens [--db <path>]
|
||||
tokenctl list-usage [--limit <n>] [--db <path>]
|
||||
```
|
||||
|
||||
示例:
|
||||
@@ -69,6 +74,7 @@ tokenctl list-tokens [--db <path>]
|
||||
```bash
|
||||
go run ./cmd/tokenctl list-users
|
||||
go run ./cmd/tokenctl list-tokens
|
||||
go run ./cmd/tokenctl list-usage --limit 100
|
||||
go run ./cmd/tokenctl issue-token --user alice --uses 5
|
||||
go run ./cmd/tokenctl disable-token --token <TOKEN>
|
||||
```
|
||||
@@ -82,6 +88,7 @@ go run ./cmd/tokenctl disable-token --token <TOKEN>
|
||||
- HUGS_PROXY_DB_PATH:SQLite 文件路径,默认 ./hugs_proxy.db
|
||||
- HUGS_PROXY_DEFAULT_TOKEN_TTL:默认 token 有效期,默认 30d
|
||||
- HUGS_PROXY_DB_BUSY_TIMEOUT_MS:SQLite busy_timeout,默认 5000
|
||||
- HUGS_PROXY_GITEA_REPO_URL:Web 首页底部“我的 Gitea 仓库”跳转地址
|
||||
|
||||
说明:
|
||||
|
||||
|
||||
+42
-2
@@ -33,6 +33,8 @@ func main() {
|
||||
handleListUsers(cfg, os.Args[2:])
|
||||
case "list-tokens":
|
||||
handleListTokens(cfg, os.Args[2:])
|
||||
case "list-usage":
|
||||
handleListUsage(cfg, os.Args[2:])
|
||||
default:
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
@@ -160,7 +162,44 @@ func handleListTokens(cfg config.Config, args []string) {
|
||||
if t.MaxUses == 0 {
|
||||
maxUsesDisplay = "unlimited"
|
||||
}
|
||||
fmt.Printf("%d\t%s\t%t\t%d/%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Disabled, t.UsedCount, maxUsesDisplay, t.ExpiresAt.Format(time.RFC3339), t.CreatedAt.Format(time.RFC3339), t.Token)
|
||||
fmt.Printf("%d\t%s\t%t\t%d/%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Disabled, t.UsedCount, maxUsesDisplay, t.ExpiresAt.Format(time.RFC3339), t.CreatedAt.Format(time.RFC3339), t.Token.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func handleListUsage(cfg config.Config, args []string) {
|
||||
fs := flag.NewFlagSet("list-usage", flag.ExitOnError)
|
||||
dbPath := fs.String("db", cfg.DBPath, "SQLite DB path")
|
||||
limit := fs.Int("limit", 50, "max rows to show, <=0 means all")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
store := openStore(*dbPath, cfg.BusyTimeoutMS)
|
||||
defer store.Close()
|
||||
|
||||
entries, err := store.ListUsageEntries(context.Background(), *limit)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("time\trequest_ip\tuser_id\ttoken_id\thttp_status\tsuccess\terror_reason\toriginal_url")
|
||||
for _, e := range entries {
|
||||
userID := "-"
|
||||
tokenID := "-"
|
||||
if e.UserID.Valid {
|
||||
userID = fmt.Sprintf("%d", e.UserID.Int64)
|
||||
}
|
||||
if e.TokenID.Valid {
|
||||
tokenID = fmt.Sprintf("%d", e.TokenID.Int64)
|
||||
}
|
||||
fmt.Printf("%s\t%s\t%s\t%s\t%d\t%t\t%s\t%s\n",
|
||||
e.OccurredAt.Format(time.RFC3339),
|
||||
e.RequestIP,
|
||||
userID,
|
||||
tokenID,
|
||||
e.HTTPStatus,
|
||||
e.Success,
|
||||
e.ErrorReason,
|
||||
e.OriginalURL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,5 +233,6 @@ func printUsage() {
|
||||
tokenctl issue-token --user <username> [--expires <7d|30d|RFC3339>] [--uses <n>] [--db <path>]
|
||||
tokenctl disable-token --token <token> [--db <path>]
|
||||
tokenctl list-users [--db <path>]
|
||||
tokenctl list-tokens [--db <path>]`)
|
||||
tokenctl list-tokens [--db <path>]
|
||||
tokenctl list-usage [--limit <n>] [--db <path>]`)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
DBPath string
|
||||
GiteaRepoURL string
|
||||
DefaultTokenTTL time.Duration
|
||||
BusyTimeoutMS int
|
||||
}
|
||||
@@ -27,6 +28,7 @@ func Load() Config {
|
||||
host := getenvOrDefault("HUGS_PROXY_HOST", defaultHost)
|
||||
port := getenvOrDefault("HUGS_PROXY_PORT", defaultPort)
|
||||
dbPath := getenvOrDefault("HUGS_PROXY_DB_PATH", defaultDBPath)
|
||||
giteaRepoURL := getenvOrDefault("HUGS_PROXY_GITEA_REPO_URL", "https://gitea.gangary.cn/gary/Hugs-Proxy")
|
||||
ttlRaw := getenvOrDefault("HUGS_PROXY_DEFAULT_TOKEN_TTL", defaultTokenTTLString)
|
||||
busyTimeout := getenvIntOrDefault("HUGS_PROXY_DB_BUSY_TIMEOUT_MS", defaultBusyTimeoutMilli)
|
||||
|
||||
@@ -39,6 +41,7 @@ func Load() Config {
|
||||
Host: host,
|
||||
Port: port,
|
||||
DBPath: dbPath,
|
||||
GiteaRepoURL: giteaRepoURL,
|
||||
DefaultTokenTTL: ttl,
|
||||
BusyTimeoutMS: busyTimeout,
|
||||
}
|
||||
|
||||
+39
-1
@@ -363,7 +363,7 @@ func (s *Store) ListTokens(ctx context.Context) ([]TokenWithUser, error) {
|
||||
var expiresAt string
|
||||
var disabledAt sql.NullString
|
||||
var disabledInt int
|
||||
if err := rows.Scan(&t.ID, &t.UserID, &t.Token, &createdAt, &expiresAt, &disabledInt, &disabledAt, &t.MaxUses, &t.UsedCount, &t.Username); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.UserID, &t.Token.Token, &createdAt, &expiresAt, &disabledInt, &disabledAt, &t.MaxUses, &t.UsedCount, &t.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct, err := time.Parse(timeFormat, createdAt)
|
||||
@@ -392,6 +392,44 @@ func (s *Store) ListTokens(ctx context.Context) ([]TokenWithUser, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListUsageEntries(ctx context.Context, limit int) ([]UsageEntry, error) {
|
||||
query := `SELECT created_at, request_ip, user_id, token_id, original_url, http_status, success, error_reason
|
||||
FROM token_usage
|
||||
ORDER BY id DESC`
|
||||
args := []any{}
|
||||
if limit > 0 {
|
||||
query += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []UsageEntry
|
||||
for rows.Next() {
|
||||
var e UsageEntry
|
||||
var createdAt string
|
||||
var successInt int
|
||||
if err := rows.Scan(&createdAt, &e.RequestIP, &e.UserID, &e.TokenID, &e.OriginalURL, &e.HTTPStatus, &successInt, &e.ErrorReason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := time.Parse(timeFormat, createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.OccurredAt = parsed
|
||||
e.Success = successInt == 1
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) RecordUsageAndMaybeIncrement(ctx context.Context, entry UsageEntry, increment bool) error {
|
||||
if entry.OccurredAt.IsZero() {
|
||||
entry.OccurredAt = time.Now().UTC()
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -52,6 +53,242 @@ var (
|
||||
auditor *audit.Logger
|
||||
)
|
||||
|
||||
const landingPageHTML = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hugs-Proxy</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg1: #070a12;
|
||||
--bg2: #0b1024;
|
||||
--fg: rgba(255,255,255,.92);
|
||||
--muted: rgba(255,255,255,.65);
|
||||
--card: rgba(255,255,255,.06);
|
||||
--card2: rgba(255,255,255,.10);
|
||||
--stroke: rgba(255,255,255,.16);
|
||||
--accent: #6ee7ff;
|
||||
--accent2: #a78bfa;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
color: var(--fg);
|
||||
background: radial-gradient(1200px 700px at 10% 10%, rgba(167,139,250,.18), transparent 60%),
|
||||
radial-gradient(900px 600px at 90% 20%, rgba(110,231,255,.18), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg1), var(--bg2));
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.grid {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,.05) 1px, transparent 1px);
|
||||
background-size: 80px 80px;
|
||||
mask-image: radial-gradient(60% 60% at 50% 30%, black 30%, transparent 75%);
|
||||
opacity: .25;
|
||||
pointer-events: none;
|
||||
transform: translateZ(0);
|
||||
animation: drift 14s linear infinite;
|
||||
}
|
||||
@keyframes drift {
|
||||
0% { transform: translate3d(0,0,0); }
|
||||
100% { transform: translate3d(-80px,-80px,0); }
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 20px 24px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
grid-template-columns: 1.15fr .85fr;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.badge {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(110,231,255,.25), rgba(167,139,250,.25));
|
||||
border: 1px solid var(--stroke);
|
||||
box-shadow: 0 18px 50px rgba(0,0,0,.4);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.badge svg { opacity: .92; }
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 42px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.sub {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--card), rgba(255,255,255,.03));
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 18px 60px rgba(0,0,0,.45);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
letter-spacing: .02em;
|
||||
color: rgba(255,255,255,.9);
|
||||
}
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,.72);
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
background: rgba(0,0,0,.22);
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: rgba(110,231,255,.45);
|
||||
box-shadow: 0 0 0 4px rgba(110,231,255,.12);
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.btn {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
background: linear-gradient(135deg, rgba(110,231,255,.20), rgba(167,139,250,.20));
|
||||
color: rgba(255,255,255,.92);
|
||||
font-weight: 600;
|
||||
letter-spacing: .02em;
|
||||
cursor: pointer;
|
||||
transition: transform .08s ease, border-color .2s ease, background .2s ease;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); border-color: rgba(255,255,255,.26); }
|
||||
.btn:active { transform: translateY(0px); }
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
color: rgba(255,255,255,.58);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.gitea {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.14);
|
||||
background: rgba(255,255,255,.04);
|
||||
text-decoration: none;
|
||||
color: rgba(255,255,255,.8);
|
||||
transition: background .2s ease, border-color .2s ease;
|
||||
}
|
||||
.gitea:hover { background: rgba(255,255,255,.06); border-color: rgba(255,255,255,.24); }
|
||||
.gitea svg { opacity: .9; }
|
||||
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid" aria-hidden="true"></div>
|
||||
<div class="wrap">
|
||||
<div class="hero">
|
||||
<div class="card">
|
||||
<div class="brand">
|
||||
<div class="badge" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Z" stroke="rgba(255,255,255,.9)" stroke-width="1.6"/>
|
||||
<path d="M7.5 12h9" stroke="rgba(255,255,255,.9)" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<path d="M12 7.5v9" stroke="rgba(255,255,255,.9)" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Hugs-Proxy</h1>
|
||||
<p class="sub">轻量的学术资源加速反向代理:支持常见 GitHub/Raw/Gist 下载格式,并带 SQLite 鉴权与访问审计。</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li>把目标 URL 放在路径里即可加速:<span style="color:rgba(255,255,255,.78)"><b>https://github.com/...</b></span></li>
|
||||
<li>Bearer Token 鉴权与使用次数控制</li>
|
||||
<li>重定向 Location 改写,尽量全程走代理</li>
|
||||
<li>大文件保护(>1GB 自动 302 回源)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>加速下载</h2>
|
||||
<form class="row" action="/download" method="post">
|
||||
<div>
|
||||
<label for="url">GitHub 链接</label>
|
||||
<input id="url" name="url" inputmode="url" autocomplete="url" placeholder="https://github.com/OWNER/REPO/releases/download/..." required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="token">Token(Hugs-Proxy 签发)</label>
|
||||
<input id="token" name="token" type="password" autocomplete="off" placeholder="粘贴 token" required />
|
||||
</div>
|
||||
<button class="btn" type="submit">下载(走加速代理)</button>
|
||||
<p class="hint">提示:token 通过表单 POST 发送,不会出现在地址栏。也可使用 curl:` + "\n" + `curl -L -H \"Authorization: Bearer <TOKEN>\" \"https://hugs.you/https://github.com/...\"</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<a class="gitea" href="{{GITEA_REPO_URL}}" target="_blank" rel="noreferrer">
|
||||
<span class="sr">Gitea Repo</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M8 19a4 4 0 0 1-4-4V7.8a3.8 3.8 0 0 1 3.8-3.8H16" stroke="rgba(255,255,255,.85)" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<path d="M10 5h7a3 3 0 0 1 3 3v11" stroke="rgba(255,255,255,.85)" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<path d="M10 9h10" stroke="rgba(255,255,255,.65)" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<path d="M10 13h10" stroke="rgba(255,255,255,.65)" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Gitea 项目仓库</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func init() {
|
||||
// 1. 初始化列表
|
||||
whiteList = parseList(whiteListStr)
|
||||
@@ -79,7 +316,23 @@ func main() {
|
||||
|
||||
base := http.HandlerFunc(routeHandler)
|
||||
protected := auth.Middleware(store, auditAuthFailure, base)
|
||||
http.Handle("/", protected)
|
||||
root := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/" && (r.Method == http.MethodGet || r.Method == http.MethodHead):
|
||||
serveLandingPage(w, r, cfg)
|
||||
return
|
||||
case r.URL.Path == "/favicon.ico" || r.URL.Path == "/webicon.png":
|
||||
serveWebIcon(w, r)
|
||||
return
|
||||
case r.URL.Path == "/download":
|
||||
downloadHandler(w, r)
|
||||
return
|
||||
default:
|
||||
protected.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
})
|
||||
http.Handle("/", root)
|
||||
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
||||
log.Printf("服务器启动成功,正在监听 %s", addr)
|
||||
@@ -88,6 +341,217 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func serveWebIcon(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "./webicon.png")
|
||||
}
|
||||
|
||||
func serveLandingPage(w http.ResponseWriter, r *http.Request, cfg config.Config) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
page := strings.ReplaceAll(landingPageHTML, "{{GITEA_REPO_URL}}", htmlAttrEscape(cfg.GiteaRepoURL))
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, page)
|
||||
}
|
||||
|
||||
func downloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 为了避免 token 出现在 URL,下载入口仅支持表单 POST。
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// ParseForm 会读取 body(application/x-www-form-urlencoded / multipart/form-data)。
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
urlInput := strings.TrimSpace(r.FormValue("url"))
|
||||
tokenValue := strings.TrimSpace(r.FormValue("token"))
|
||||
if tokenValue == "" {
|
||||
auditRequestFailure(r, http.StatusUnauthorized, "missing_token", 0, 0, urlInput)
|
||||
http.Error(w, "Missing token.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 先校验并消耗一次 token。
|
||||
tok, err := store.ValidateAndConsumeToken(r.Context(), tokenValue)
|
||||
if err != nil {
|
||||
status, reason := classifyTokenError(err)
|
||||
|
||||
var userID int64
|
||||
var tokenID int64
|
||||
if !errors.Is(err, db.ErrNotFound) {
|
||||
if t, getErr := store.GetToken(r.Context(), tokenValue); getErr == nil {
|
||||
userID = t.UserID
|
||||
tokenID = t.ID
|
||||
}
|
||||
}
|
||||
|
||||
auditRequestFailure(r, status, reason, userID, tokenID, urlInput)
|
||||
http.Error(w, http.StatusText(status), status)
|
||||
return
|
||||
}
|
||||
|
||||
recorder := newStatusRecorder(w)
|
||||
normalizedURL := urlInput
|
||||
success := false
|
||||
errorReason := ""
|
||||
|
||||
defer func() {
|
||||
statusCode := recorder.StatusCode()
|
||||
if statusCode >= 200 && statusCode < 400 {
|
||||
success = true
|
||||
}
|
||||
if err := auditor.Log(r.Context(), audit.Entry{
|
||||
RequestIP: clientIP(r),
|
||||
UserID: tok.UserID,
|
||||
HasUser: true,
|
||||
TokenID: tok.ID,
|
||||
HasToken: true,
|
||||
OriginalURL: normalizedURL,
|
||||
HTTPStatus: statusCode,
|
||||
Success: success,
|
||||
ErrorReason: errorReason,
|
||||
CountAsSuccess: success,
|
||||
}); err != nil {
|
||||
log.Printf("audit log failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if urlInput == "" {
|
||||
errorReason = "missing_url"
|
||||
http.Error(recorder, "Missing url.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
u, prepErr := prepareTargetURL(urlInput)
|
||||
if prepErr != nil {
|
||||
errorReason = prepErr.Reason
|
||||
http.Error(recorder, prepErr.Message, prepErr.StatusCode)
|
||||
return
|
||||
}
|
||||
normalizedURL = u
|
||||
|
||||
// 表单是 POST,但下载应当总是 GET。
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.Method = http.MethodGet
|
||||
r2.Body = nil
|
||||
proxy(recorder, r2, u)
|
||||
}
|
||||
|
||||
func classifyTokenError(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
return http.StatusForbidden, "invalid_token"
|
||||
case errors.Is(err, db.ErrTokenDisabled):
|
||||
return http.StatusForbidden, "token_disabled"
|
||||
case errors.Is(err, db.ErrTokenExpired):
|
||||
return http.StatusForbidden, "token_expired"
|
||||
case errors.Is(err, db.ErrTokenExhausted):
|
||||
return http.StatusForbidden, "token_exhausted"
|
||||
default:
|
||||
return http.StatusInternalServerError, "token_lookup_error"
|
||||
}
|
||||
}
|
||||
|
||||
func auditRequestFailure(r *http.Request, statusCode int, reason string, userID int64, tokenID int64, originalURL string) {
|
||||
entry := audit.Entry{
|
||||
RequestIP: clientIP(r),
|
||||
OriginalURL: strings.TrimSpace(originalURL),
|
||||
HTTPStatus: statusCode,
|
||||
Success: false,
|
||||
ErrorReason: reason,
|
||||
CountAsSuccess: false,
|
||||
}
|
||||
if entry.OriginalURL == "" {
|
||||
entry.OriginalURL = strings.TrimPrefix(r.URL.RequestURI(), "/")
|
||||
}
|
||||
if userID > 0 {
|
||||
entry.UserID = userID
|
||||
entry.HasUser = true
|
||||
}
|
||||
if tokenID > 0 {
|
||||
entry.TokenID = tokenID
|
||||
entry.HasToken = true
|
||||
}
|
||||
if err := auditor.Log(context.Background(), entry); err != nil {
|
||||
log.Printf("audit failure log failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func htmlAttrEscape(s string) string {
|
||||
// 只用于 attribute:非常轻量的转义,避免引入额外依赖。
|
||||
// 允许 http(s) URL;其他字符做最小替换。
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
type targetPrepareError struct {
|
||||
StatusCode int
|
||||
Reason string
|
||||
Message string
|
||||
}
|
||||
|
||||
func prepareTargetURL(raw string) (string, *targetPrepareError) {
|
||||
u := strings.TrimSpace(raw)
|
||||
if u == "" {
|
||||
return "", &targetPrepareError{StatusCode: http.StatusBadRequest, Reason: "missing_url", Message: "Missing url."}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(u, "https:/") && !strings.HasPrefix(u, "https://") {
|
||||
u = "https://" + strings.TrimPrefix(u, "https:/")
|
||||
} else if strings.HasPrefix(u, "http:/") && !strings.HasPrefix(u, "http://") {
|
||||
u = "http://" + strings.TrimPrefix(u, "http:/")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(u, "http") {
|
||||
u = "https://" + u
|
||||
}
|
||||
|
||||
m := checkURL(u)
|
||||
if m == nil {
|
||||
return "", &targetPrepareError{StatusCode: http.StatusForbidden, Reason: "invalid_input", Message: "Invalid input."}
|
||||
}
|
||||
|
||||
if len(whiteList) > 0 {
|
||||
allowed := false
|
||||
for _, i := range whiteList {
|
||||
if matchRule(m, i) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return "", &targetPrepareError{StatusCode: http.StatusForbidden, Reason: "forbidden_by_white_list", Message: "Forbidden by white list."}
|
||||
}
|
||||
}
|
||||
|
||||
for _, i := range blackList {
|
||||
if matchRule(m, i) {
|
||||
return "", &targetPrepareError{StatusCode: http.StatusForbidden, Reason: "forbidden_by_black_list", Message: "Forbidden by black list."}
|
||||
}
|
||||
}
|
||||
|
||||
// 将网页浏览的blob链接统一替换为raw下载链接
|
||||
if exp2.MatchString(u) {
|
||||
u = strings.Replace(u, "/blob/", "/raw/", 1)
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(u)
|
||||
if err == nil {
|
||||
u = parsedURL.String()
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func routeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
authInfo, ok := auth.FromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -122,57 +586,14 @@ func routeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
u := originalInput
|
||||
if strings.HasPrefix(u, "https:/") && !strings.HasPrefix(u, "https://") {
|
||||
u = "https://" + strings.TrimPrefix(u, "https:/")
|
||||
} else if strings.HasPrefix(u, "http:/") && !strings.HasPrefix(u, "http://") {
|
||||
u = "http://" + strings.TrimPrefix(u, "http:/")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(u, "http") {
|
||||
u = "https://" + u
|
||||
}
|
||||
|
||||
m := checkURL(u)
|
||||
if m == nil {
|
||||
errorReason = "invalid_input"
|
||||
http.Error(recorder, "Invalid input.", http.StatusForbidden)
|
||||
u, prepErr := prepareTargetURL(originalInput)
|
||||
if prepErr != nil {
|
||||
errorReason = prepErr.Reason
|
||||
http.Error(recorder, prepErr.Message, prepErr.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
if len(whiteList) > 0 {
|
||||
allowed := false
|
||||
for _, i := range whiteList {
|
||||
if matchRule(m, i) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
errorReason = "forbidden_by_white_list"
|
||||
http.Error(recorder, "Forbidden by white list.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, i := range blackList {
|
||||
if matchRule(m, i) {
|
||||
errorReason = "forbidden_by_black_list"
|
||||
http.Error(recorder, "Forbidden by black list.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 将网页浏览的blob链接统一替换为raw下载链接
|
||||
if exp2.MatchString(u) {
|
||||
u = strings.Replace(u, "/blob/", "/raw/", 1)
|
||||
}
|
||||
|
||||
// 发起代理请求
|
||||
parsedURL, err := url.Parse(u)
|
||||
if err == nil {
|
||||
u = parsedURL.String()
|
||||
}
|
||||
normalizedURL = u
|
||||
proxy(recorder, r, u)
|
||||
}
|
||||
@@ -236,25 +657,7 @@ func proxy(w http.ResponseWriter, r *http.Request, targetURL string) {
|
||||
}
|
||||
|
||||
func auditAuthFailure(r *http.Request, token string, statusCode int, reason string, userID int64, tokenID int64) {
|
||||
entry := audit.Entry{
|
||||
RequestIP: clientIP(r),
|
||||
OriginalURL: strings.TrimPrefix(r.URL.RequestURI(), "/"),
|
||||
HTTPStatus: statusCode,
|
||||
Success: false,
|
||||
ErrorReason: reason,
|
||||
CountAsSuccess: false,
|
||||
}
|
||||
if userID > 0 {
|
||||
entry.UserID = userID
|
||||
entry.HasUser = true
|
||||
}
|
||||
if tokenID > 0 {
|
||||
entry.TokenID = tokenID
|
||||
entry.HasToken = true
|
||||
}
|
||||
if err := auditor.Log(context.Background(), entry); err != nil {
|
||||
log.Printf("auth failure audit log failed: %v", err)
|
||||
}
|
||||
auditRequestFailure(r, statusCode, reason, userID, tokenID, "")
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Reference in New Issue
Block a user