Files
vpwg/Go/internal/web/handlers/share.go
T

278 lines
8.4 KiB
Go

package handlers
import (
"errors"
"fmt"
"net/http"
"strings"
"amnezia-share/internal/i18n"
"amnezia-share/internal/models"
"amnezia-share/internal/share"
"amnezia-share/internal/web"
)
func registerShare(a *web.App, mux *http.ServeMux) {
mux.HandleFunc("GET /share", ShareGet(a))
mux.HandleFunc("POST /share", a.CSRFProtect(SharePost(a)))
mux.HandleFunc("GET /share/servers", ShareServers(a))
mux.HandleFunc("GET /share/download", ShareDownload(a))
}
type shareView struct {
Token string
Link *models.ShareLink
Servers []models.ServerInfo
Bundles []share.Bundle
Maintenance bool
Expired bool
LimitReached bool
NotFound bool
OK string
Error string
}
func buildBundles(creations []models.ShareCreation) []share.Bundle {
out := make([]share.Bundle, 0, len(creations))
for _, c := range creations {
if c.ResponseJSON == nil || strings.TrimSpace(*c.ResponseJSON) == "" {
continue
}
b := share.BundleFromResponseJSON(c.Protocol, c.ConnectionName, *c.ResponseJSON)
b.CreationID = c.ID
b.CreatedAt = c.CreatedAt
b.ServerID = c.ServerID
out = append(out, b)
}
return share.SortBundlesWireguardFirst(out)
}
func guestServersForLink(a *web.App, r *http.Request, link *models.ShareLink) []models.ServerInfo {
ctx := r.Context()
labels := a.Settings.ServerLabels(ctx)
servers := share.BuildGuestServers(ctx, a.Settings, labels)
if link != nil {
servers = share.FilterServersForLink(*link, servers)
}
return servers
}
// renderSharePage re-loads the link fresh (so post-action state is current) and
// renders the guest share page.
func renderSharePage(a *web.App, w http.ResponseWriter, r *http.Request, token, okMsg, errText string) {
ctx := r.Context()
view := shareView{Token: token, OK: okMsg, Error: errText}
link, err := a.Share.LinkByToken(ctx, token)
if err != nil || link == nil {
view.NotFound = true
a.Render(w, r, "page_share", view)
return
}
view.Link = link
view.Expired = link.IsExpired() || link.CleanedAt != nil
view.LimitReached = link.MaxUses > 0 && link.UseCount >= link.MaxUses
view.Maintenance = a.Settings.Maintenance(ctx)
view.Servers = guestServersForLink(a, r, link)
creations, _ := a.Share.ListCreationsForLink(ctx, link.ID)
view.Bundles = buildBundles(creations)
a.Render(w, r, "page_share", view)
}
// ShareGet renders the guest share page (share.php GET).
func ShareGet(a *web.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if lg := i18n.LangFromRequest(r); lg != "" {
i18n.Persist(w, r, a.Sessions, i18n.NormalizeLang(lg), false)
}
token := r.URL.Query().Get("k")
renderSharePage(a, w, r, token, "", "")
}
}
func findActiveCreation(creations []models.ShareCreation, id int64) *models.ShareCreation {
for i := range creations {
if creations[i].ID == id {
return &creations[i]
}
}
return nil
}
// SharePost handles the guest actions on the share page: create / migrate /
// renew (share.php POST). Responds JSON when X-Share-Async:1 is set, otherwise
// re-renders the full page (share.php's classic form-post behaviour).
func SharePost(a *web.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token := r.FormValue("k")
lang := i18n.Resolve(r, a.Sessions)
link, err := a.Share.LinkByToken(ctx, token)
if err != nil || link == nil {
finishShareAction(a, w, r, token, "", i18n.T(lang, "err_link_not_found", nil))
return
}
var actionErr error
var okMsg string
action := r.FormValue("action")
maintenance := a.Settings.Maintenance(ctx)
switch action {
case "create":
if maintenance {
actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil))
break
}
serverID := formInt(r, "server_id", -1)
protocol := strings.TrimSpace(r.FormValue("protocol"))
if !share.LinkServerAllowed(*link, serverID) {
actionErr = errors.New(i18n.T(lang, "err_pick_server", nil))
break
}
if a.Settings.DisabledServers(ctx)[serverID] {
actionErr = errors.New(i18n.T(lang, "err_server_disabled", nil))
break
}
if !share.ServerProtocolAllowed(ctx, a.Settings, serverID, protocol) {
actionErr = errors.New(i18n.T(lang, "proto_unavailable", nil))
break
}
_, addErr := a.Share.TryAddConnection(ctx, a.Panel, a.Settings, link, serverID, protocol)
if addErr != nil {
actionErr = addErr
break
}
fresh, _ := a.Share.LinkByID(ctx, link.ID)
remaining, max := 0, 0
if fresh != nil {
max = fresh.MaxUses
remaining = fresh.MaxUses - fresh.UseCount
}
okMsg = i18n.T(lang, "ok_created", map[string]string{"remaining": fmt.Sprint(remaining), "max": fmt.Sprint(max)})
case "migrate":
if maintenance {
actionErr = errors.New(i18n.T(lang, "err_maintenance_mode", nil))
break
}
creationID := formInt64(r, "creation_id", 0)
newServerID := formInt(r, "new_server_id", -1)
newProtocol := strings.TrimSpace(r.FormValue("new_protocol"))
if !share.LinkServerAllowed(*link, newServerID) {
actionErr = errors.New(i18n.T(lang, "err_pick_migrate_server", nil))
break
}
if a.Settings.DisabledServers(ctx)[newServerID] {
actionErr = errors.New(i18n.T(lang, "err_server_disabled", nil))
break
}
if !share.ServerProtocolAllowed(ctx, a.Settings, newServerID, newProtocol) {
actionErr = errors.New(i18n.T(lang, "proto_unavailable", nil))
break
}
if err := a.Share.TryMigrateConnection(ctx, a.Panel, a.Settings, link, creationID, newServerID, newProtocol); err != nil {
actionErr = err
break
}
okMsg = i18n.T(lang, "ok_migrated", nil)
case "renew":
code := r.FormValue("code")
res, err := a.Share.TryRedeemGuest(ctx, token, code)
if err != nil {
actionErr = err
break
}
until := ""
if res.Link != nil {
until = res.Link.ExpiresLabel()
}
okMsg = i18n.T(lang, "ok_renewed", map[string]string{"days": fmt.Sprint(res.AddDays), "until": until})
default:
actionErr = errors.New("Неизвестное действие.")
}
errText := ""
if actionErr != nil {
errText = i18n.TranslateGuestError(lang, actionErr.Error())
}
finishShareAction(a, w, r, token, okMsg, errText)
}
}
func finishShareAction(a *web.App, w http.ResponseWriter, r *http.Request, token, okMsg, errText string) {
if wantsJSON(r) {
if errText != "" {
writeJSONError(w, http.StatusBadRequest, errText)
return
}
writeJSONOK(w, map[string]any{"message": okMsg})
return
}
renderSharePage(a, w, r, token, okMsg, errText)
}
// ShareServers returns the guest-visible server catalog for a link as JSON,
// used by the page's async refresh (share_servers.php).
func ShareServers(a *web.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token := r.URL.Query().Get("k")
link, err := a.Share.LinkByToken(ctx, token)
if err != nil || link == nil {
writeJSONError(w, http.StatusNotFound, "Ссылка не найдена.")
return
}
writeJSONOK(w, map[string]any{"servers": guestServersForLink(a, r, link)})
}
}
// ShareDownload streams a single downloadable artifact (.conf / .vpn / .zip)
// for one of the link's created connections (share_downloads.php).
func ShareDownload(a *web.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := r.URL.Query()
token := q.Get("k")
creationID := queryInt(r, "cre", 0)
part := q.Get("part")
link, err := a.Share.LinkByToken(ctx, token)
if err != nil || link == nil {
http.Error(w, "Ссылка не найдена.", http.StatusNotFound)
return
}
creations, err := a.Share.ListCreationsForLink(ctx, link.ID)
if err != nil {
http.Error(w, "Ошибка сервера.", http.StatusInternalServerError)
return
}
creation := findActiveCreation(creations, int64(creationID))
if creation == nil || creation.ResponseJSON == nil {
http.Error(w, "Файл не найден.", http.StatusNotFound)
return
}
fp := share.DownloadPayloadForPart(creation.Protocol, creation.ConnectionName, *creation.ResponseJSON, part)
if fp == nil {
http.Error(w, "Файл не найден.", http.StatusNotFound)
return
}
serveFilePart(w, fp)
}
}
func serveFilePart(w http.ResponseWriter, fp *share.FilePart) {
mime := fp.Mime
if mime == "" {
mime = "text/plain; charset=utf-8"
}
w.Header().Set("Content-Type", mime)
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(fp.Filename, `"`, "")+`"`)
_, _ = w.Write([]byte(fp.Body))
}