feat(config): use encode/decode to handle url #249

This commit is contained in:
Jacky 2025-04-06 10:55:09 +00:00
parent 4b8d26cf5b
commit 191ddea309
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
19 changed files with 235 additions and 82 deletions

View file

@ -2,6 +2,7 @@ package config
import ( import (
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -28,8 +29,22 @@ func AddConfig(c *gin.Context) {
name := json.Name name := json.Name
content := json.Content content := json.Content
dir := nginx.GetConfPath(json.BaseDir)
path := filepath.Join(dir, json.Name) // Decode paths from URL encoding
decodedBaseDir, err := url.QueryUnescape(json.BaseDir)
if err != nil {
cosy.ErrHandler(c, err)
return
}
decodedName, err := url.QueryUnescape(name)
if err != nil {
cosy.ErrHandler(c, err)
return
}
dir := nginx.GetConfPath(decodedBaseDir)
path := filepath.Join(dir, decodedName)
if !helper.IsUnderDirectory(path, nginx.GetConfPath()) { if !helper.IsUnderDirectory(path, nginx.GetConfPath()) {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"message": "filepath is not under the nginx conf path", "message": "filepath is not under the nginx conf path",
@ -53,7 +68,7 @@ func AddConfig(c *gin.Context) {
} }
} }
err := os.WriteFile(path, []byte(content), 0644) err = os.WriteFile(path, []byte(content), 0644)
if err != nil { if err != nil {
cosy.ErrHandler(c, err) cosy.ErrHandler(c, err)
return return

View file

@ -2,6 +2,7 @@ package config
import ( import (
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
@ -23,6 +24,19 @@ type APIConfigResp struct {
func GetConfig(c *gin.Context) { func GetConfig(c *gin.Context) {
relativePath := c.Param("path") relativePath := c.Param("path")
// Ensure the path is correctly decoded - handle cases where it might be encoded multiple times
decodedPath := relativePath
var err error
// Try decoding until the path no longer changes
for {
newDecodedPath, decodeErr := url.PathUnescape(decodedPath)
if decodeErr != nil || newDecodedPath == decodedPath {
break
}
decodedPath = newDecodedPath
}
relativePath = decodedPath
absPath := nginx.GetConfPath(relativePath) absPath := nginx.GetConfPath(relativePath)
if !helper.IsUnderDirectory(absPath, nginx.GetConfPath()) { if !helper.IsUnderDirectory(absPath, nginx.GetConfPath()) {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{

View file

@ -2,6 +2,7 @@ package config
import ( import (
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
@ -16,7 +17,31 @@ func GetConfigs(c *gin.Context) {
name := c.Query("name") name := c.Query("name")
sortBy := c.Query("sort_by") sortBy := c.Query("sort_by")
order := c.DefaultQuery("order", "desc") order := c.DefaultQuery("order", "desc")
dir := c.DefaultQuery("dir", "/")
// Get directory parameter
encodedDir := c.DefaultQuery("dir", "/")
// Handle cases where the path might be encoded multiple times
dir := encodedDir
// Try decoding until the path no longer changes
for {
newDecodedDir, decodeErr := url.QueryUnescape(dir)
if decodeErr != nil {
cosy.ErrHandler(c, decodeErr)
return
}
if newDecodedDir == dir {
break
}
dir = newDecodedDir
}
// Ensure the directory path format is correct
dir = strings.TrimSpace(dir)
if dir != "/" && strings.HasSuffix(dir, "/") {
dir = strings.TrimSuffix(dir, "/")
}
configFiles, err := os.ReadDir(nginx.GetConfPath(dir)) configFiles, err := os.ReadDir(nginx.GetConfPath(dir))
if err != nil { if err != nil {

View file

@ -2,6 +2,7 @@ package config
import ( import (
"net/http" "net/http"
"net/url"
"os" "os"
"github.com/0xJacky/Nginx-UI/internal/helper" "github.com/0xJacky/Nginx-UI/internal/helper"
@ -18,7 +19,21 @@ func Mkdir(c *gin.Context) {
if !cosy.BindAndValid(c, &json) { if !cosy.BindAndValid(c, &json) {
return return
} }
fullPath := nginx.GetConfPath(json.BasePath, json.FolderName)
// Ensure paths are properly URL unescaped
decodedBasePath, err := url.QueryUnescape(json.BasePath)
if err != nil {
cosy.ErrHandler(c, err)
return
}
decodedFolderName, err := url.QueryUnescape(json.FolderName)
if err != nil {
cosy.ErrHandler(c, err)
return
}
fullPath := nginx.GetConfPath(decodedBasePath, decodedFolderName)
if !helper.IsUnderDirectory(fullPath, nginx.GetConfPath()) { if !helper.IsUnderDirectory(fullPath, nginx.GetConfPath()) {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"message": "You are not allowed to create a folder " + "message": "You are not allowed to create a folder " +
@ -26,7 +41,7 @@ func Mkdir(c *gin.Context) {
}) })
return return
} }
err := os.Mkdir(fullPath, 0755) err = os.Mkdir(fullPath, 0755)
if err != nil { if err != nil {
cosy.ErrHandler(c, err) cosy.ErrHandler(c, err)
return return

View file

@ -2,6 +2,7 @@ package config
import ( import (
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -23,6 +24,20 @@ type EditConfigJson struct {
func EditConfig(c *gin.Context) { func EditConfig(c *gin.Context) {
relativePath := c.Param("path") relativePath := c.Param("path")
// Ensure the path is correctly decoded - handle cases where it might be encoded multiple times
decodedPath := relativePath
var err error
// Try decoding until the path no longer changes
for {
newDecodedPath, decodeErr := url.PathUnescape(decodedPath)
if decodeErr != nil || newDecodedPath == decodedPath {
break
}
decodedPath = newDecodedPath
}
relativePath = decodedPath
var json struct { var json struct {
Content string `json:"content"` Content string `json:"content"`
SyncOverwrite bool `json:"sync_overwrite"` SyncOverwrite bool `json:"sync_overwrite"`

View file

@ -2,6 +2,7 @@ package config
import ( import (
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -32,8 +33,28 @@ func Rename(c *gin.Context) {
}) })
return return
} }
origFullPath := nginx.GetConfPath(json.BasePath, json.OrigName)
newFullPath := nginx.GetConfPath(json.BasePath, json.NewName) // Decode paths from URL encoding
decodedBasePath, err := url.QueryUnescape(json.BasePath)
if err != nil {
cosy.ErrHandler(c, err)
return
}
decodedOrigName, err := url.QueryUnescape(json.OrigName)
if err != nil {
cosy.ErrHandler(c, err)
return
}
decodedNewName, err := url.QueryUnescape(json.NewName)
if err != nil {
cosy.ErrHandler(c, err)
return
}
origFullPath := nginx.GetConfPath(decodedBasePath, decodedOrigName)
newFullPath := nginx.GetConfPath(decodedBasePath, decodedNewName)
if !helper.IsUnderDirectory(origFullPath, nginx.GetConfPath()) || if !helper.IsUnderDirectory(origFullPath, nginx.GetConfPath()) ||
!helper.IsUnderDirectory(newFullPath, nginx.GetConfPath()) { !helper.IsUnderDirectory(newFullPath, nginx.GetConfPath()) {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{

View file

@ -44,12 +44,12 @@ class Curd<T> {
// eslint-disable-next-line ts/no-explicit-any // eslint-disable-next-line ts/no-explicit-any
_get(id: any = null, params: any = {}): Promise<T> { _get(id: any = null, params: any = {}): Promise<T> {
return http.get(this.baseUrl + (id ? `/${id}` : ''), { params }) return http.get(this.baseUrl + (id ? `/${encodeURIComponent(id)}` : ''), { params })
} }
// eslint-disable-next-line ts/no-explicit-any // eslint-disable-next-line ts/no-explicit-any
_save(id: any = null, data: any = {}, config: any = undefined): Promise<T> { _save(id: any = null, data: any = {}, config: any = undefined): Promise<T> {
return http.post(this.baseUrl + (id ? `/${id}` : ''), data, config) return http.post(this.baseUrl + (id ? `/${encodeURIComponent(id)}` : ''), data, config)
} }
// eslint-disable-next-line ts/no-explicit-any // eslint-disable-next-line ts/no-explicit-any
@ -69,12 +69,12 @@ class Curd<T> {
// eslint-disable-next-line ts/no-explicit-any // eslint-disable-next-line ts/no-explicit-any
_destroy(id: any = null, params: any = {}) { _destroy(id: any = null, params: any = {}) {
return http.delete(`${this.baseUrl}/${id}`, { params }) return http.delete(`${this.baseUrl}/${encodeURIComponent(id)}`, { params })
} }
// eslint-disable-next-line ts/no-explicit-any // eslint-disable-next-line ts/no-explicit-any
_recover(id: any = null) { _recover(id: any = null) {
return http.patch(`${this.baseUrl}/${id}`) return http.patch(`${this.baseUrl}/${encodeURIComponent(id)}`)
} }
_update_order(data: { target_id: number, direction: number, affected_ids: number[] }) { _update_order(data: { target_id: number, direction: number, affected_ids: number[] }) {

View file

@ -35,7 +35,7 @@ export interface AutoCertRequest {
class SiteCurd extends Curd<Site> { class SiteCurd extends Curd<Site> {
// eslint-disable-next-line ts/no-explicit-any // eslint-disable-next-line ts/no-explicit-any
enable(name: string, config?: any) { enable(name: string, config?: any) {
return http.post(`${this.baseUrl}/${name}/enable`, undefined, config) return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/enable`, undefined, config)
} }
disable(name: string) { disable(name: string) {
@ -43,7 +43,7 @@ class SiteCurd extends Curd<Site> {
} }
rename(oldName: string, newName: string) { rename(oldName: string, newName: string) {
return http.post(`${this.baseUrl}/${oldName}/rename`, { new_name: newName }) return http.post(`${this.baseUrl}/${encodeURIComponent(oldName)}/rename`, { new_name: newName })
} }
get_default_template() { get_default_template() {
@ -51,19 +51,19 @@ class SiteCurd extends Curd<Site> {
} }
add_auto_cert(domain: string, data: AutoCertRequest) { add_auto_cert(domain: string, data: AutoCertRequest) {
return http.post(`auto_cert/${domain}`, data) return http.post(`auto_cert/${encodeURIComponent(domain)}`, data)
} }
remove_auto_cert(domain: string) { remove_auto_cert(domain: string) {
return http.delete(`auto_cert/${domain}`) return http.delete(`auto_cert/${encodeURIComponent(domain)}`)
} }
duplicate(name: string, data: { name: string }): Promise<{ dst: string }> { duplicate(name: string, data: { name: string }): Promise<{ dst: string }> {
return http.post(`${this.baseUrl}/${name}/duplicate`, data) return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/duplicate`, data)
} }
advance_mode(name: string, data: { advanced: boolean }) { advance_mode(name: string, data: { advanced: boolean }) {
return http.post(`${this.baseUrl}/${name}/advance`, data) return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/advance`, data)
} }
} }

View file

@ -21,23 +21,23 @@ export interface Stream {
class StreamCurd extends Curd<Stream> { class StreamCurd extends Curd<Stream> {
// eslint-disable-next-line ts/no-explicit-any // eslint-disable-next-line ts/no-explicit-any
enable(name: string, config?: any) { enable(name: string, config?: any) {
return http.post(`${this.baseUrl}/${name}/enable`, undefined, config) return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/enable`, undefined, config)
} }
disable(name: string) { disable(name: string) {
return http.post(`${this.baseUrl}/${name}/disable`) return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/disable`)
} }
duplicate(name: string, data: { name: string }): Promise<{ dst: string }> { duplicate(name: string, data: { name: string }): Promise<{ dst: string }> {
return http.post(`${this.baseUrl}/${name}/duplicate`, data) return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/duplicate`, data)
} }
advance_mode(name: string, data: { advanced: boolean }) { advance_mode(name: string, data: { advanced: boolean }) {
return http.post(`${this.baseUrl}/${name}/advance`, data) return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/advance`, data)
} }
rename(name: string, newName: string) { rename(name: string, newName: string) {
return http.post(`${this.baseUrl}/${name}/rename`, { new_name: newName }) return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/rename`, { new_name: newName })
} }
} }

View file

@ -24,10 +24,8 @@ const router = useRouter()
// eslint-disable-next-line vue/require-typed-ref // eslint-disable-next-line vue/require-typed-ref
const refForm = ref() const refForm = ref()
const refInspectConfig = useTemplateRef('refInspectConfig')
const origName = ref('') const origName = ref('')
const addMode = computed(() => !route.params.name) const addMode = computed(() => !route.params.name)
const errors = ref({})
const showHistory = ref(false) const showHistory = ref(false)
const basePath = computed(() => { const basePath = computed(() => {
@ -52,11 +50,15 @@ const activeKey = ref(['basic', 'deploy', 'chatgpt'])
const modifiedAt = ref('') const modifiedAt = ref('')
const nginxConfigBase = ref('') const nginxConfigBase = ref('')
const newPath = computed(() => [nginxConfigBase.value, basePath.value, data.value.name] const newPath = computed(() => {
//
const path = [nginxConfigBase.value, basePath.value, data.value.name]
.filter(v => v) .filter(v => v)
.join('/')) .join('/')
return path
})
const relativePath = computed(() => (route.params.name as string[]).join('/')) const relativePath = computed(() => (basePath.value ? `${basePath.value}/${route.params.name}` : route.params.name) as string)
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
async function init() { async function init() {
@ -75,20 +77,26 @@ async function init() {
.split('/') .split('/')
.filter(v => v) .filter(v => v)
const path = filteredPath.map((v, k) => { // Build accumulated path to maintain original encoding state
let dir = v let accumulatedPath = ''
const path = filteredPath.map((segment, index) => {
// Decode for display
const decodedSegment = decodeURIComponent(segment)
if (k > 0) { // Accumulated path keeps original encoding state
dir = filteredPath.slice(0, k).join('/') if (index === 0) {
dir += `/${v}` accumulatedPath = segment
}
else {
accumulatedPath = `${accumulatedPath}/${segment}`
} }
return { return {
name: 'Manage Configs', name: 'Manage Configs',
translatedName: () => v, translatedName: () => decodedSegment,
path: '/config', path: '/config',
query: { query: {
dir, dir: accumulatedPath,
}, },
hasChildren: false, hasChildren: false,
} }
@ -116,16 +124,30 @@ async function init() {
historyChatgptRecord.value = [] historyChatgptRecord.value = []
data.value.filepath = '' data.value.filepath = ''
const path = basePath.value const pathSegments = basePath.value
.split('/') .split('/')
.filter(v => v) .filter(v => v)
.map(v => {
// Build accumulated path
let accumulatedPath = ''
const path = pathSegments.map((segment, index) => {
// Decode for display
const decodedSegment = decodeURIComponent(segment)
// Accumulated path keeps original encoding state
if (index === 0) {
accumulatedPath = segment
}
else {
accumulatedPath = `${accumulatedPath}/${segment}`
}
return { return {
name: 'Manage Configs', name: 'Manage Configs',
translatedName: () => v, translatedName: () => decodedSegment,
path: '/config', path: '/config',
query: { query: {
dir: v, dir: accumulatedPath,
}, },
hasChildren: false, hasChildren: false,
} }
@ -167,12 +189,18 @@ function save() {
}).then(r => { }).then(r => {
data.value.content = r.content data.value.content = r.content
message.success($gettext('Saved successfully')) message.success($gettext('Saved successfully'))
router.push(`/config/${r.filepath.replaceAll(`${nginxConfigBase.value}/`, '')}/edit`)
}).catch(e => { if (addMode.value) {
errors.value = e.errors router.push({
message.error($gettext('Save error %{msg}', { msg: e.message ?? '' })) path: `/config/${data.value.name}/edit`,
}).finally(() => { query: {
refInspectConfig.value?.test() basePath: basePath.value,
},
})
}
else {
data.value = r
}
}) })
}) })
} }
@ -187,10 +215,13 @@ function formatCode() {
} }
function goBack() { function goBack() {
// Keep original path with encoding state
const encodedPath = basePath.value || ''
router.push({ router.push({
path: '/config', path: '/config',
query: { query: {
dir: basePath.value || undefined, dir: encodedPath || undefined,
}, },
}) })
} }
@ -223,7 +254,6 @@ function openHistory() {
<InspectConfig <InspectConfig
v-show="!addMode" v-show="!addMode"
ref="refInspectConfig"
/> />
<CodeEditor v-model:content="data.content" /> <CodeEditor v-model:content="data.content" />
<FooterToolBar> <FooterToolBar>
@ -281,14 +311,14 @@ function openHistory() {
v-if="!addMode" v-if="!addMode"
:label="$gettext('Path')" :label="$gettext('Path')"
> >
{{ data.filepath }} {{ decodeURIComponent(data.filepath) }}
</AFormItem> </AFormItem>
<AFormItem <AFormItem
v-show="data.name !== origName" v-show="data.name !== origName"
:label="addMode ? $gettext('New Path') : $gettext('Changed Path')" :label="addMode ? $gettext('New Path') : $gettext('Changed Path')"
required required
> >
{{ newPath }} {{ decodeURIComponent(newPath) }}
</AFormItem> </AFormItem>
<AFormItem <AFormItem
v-if="!addMode" v-if="!addMode"

View file

@ -40,20 +40,23 @@ function updateBreadcrumbs() {
.split('/') .split('/')
.filter(v => v) .filter(v => v)
const path = filteredPath.map((v, k) => { let accumulatedPath = ''
let dir = v const path = filteredPath.map((segment, index) => {
const decodedSegment = decodeURIComponent(segment)
if (k > 0) { if (index === 0) {
dir = filteredPath.slice(0, k).join('/') accumulatedPath = segment
dir += `/${v}` }
else {
accumulatedPath = `${accumulatedPath}/${segment}`
} }
return { return {
name: 'Manage Configs', name: 'Manage Configs',
translatedName: () => v, translatedName: () => decodedSegment,
path: '/config', path: '/config',
query: { query: {
dir, dir: accumulatedPath,
}, },
hasChildren: false, hasChildren: false,
} }
@ -82,10 +85,13 @@ watch(route, () => {
}) })
function goBack() { function goBack() {
const pathSegments = basePath.value.split('/').slice(0, -2)
const encodedPath = pathSegments.length > 0 ? pathSegments.join('/') : ''
router.push({ router.push({
path: '/config', path: '/config',
query: { query: {
dir: `${basePath.value.split('/').slice(0, -2).join('/')}` || undefined, dir: encodedPath || undefined,
}, },
}) })
} }
@ -144,13 +150,22 @@ const refRename = useTemplateRef('refRename')
@click="() => { @click="() => {
if (!record.is_dir) { if (!record.is_dir) {
router.push({ router.push({
path: `/config/${basePath}${record.name}/edit`, path: `/config/${encodeURIComponent(record.name)}/edit`,
query: {
basePath,
},
}) })
} }
else { else {
let encodedPath = '';
if (basePath) {
encodedPath = basePath;
}
encodedPath += encodeURIComponent(record.name);
router.push({ router.push({
query: { query: {
dir: basePath + record.name, dir: encodedPath,
}, },
}) })
} }

View file

@ -29,7 +29,10 @@ function save() {
modify.value = false modify.value = false
message.success($gettext('Renamed successfully')) message.success($gettext('Renamed successfully'))
router.push({ router.push({
path: `/config/${r.path}/edit`, path: `/config/${encodeURIComponent(buffer.value)}/edit`,
query: {
basePath: encodeURIComponent(props.dir!),
},
}) })
}).finally(() => { }).finally(() => {
loading.value = false loading.value = false

View file

@ -37,9 +37,11 @@ function ok() {
const otpModal = use2FAModal() const otpModal = use2FAModal()
otpModal.open().then(() => { otpModal.open().then(() => {
config.rename(basePath, orig_name, new_name, sync_node_ids).then(() => { // Note: API will handle URL encoding of path segments
config.rename(basePath, orig_name, new_name, sync_node_ids).then(r => {
visible.value = false visible.value = false
message.success($gettext('Rename successfully')) message.success($gettext('Rename successfully'))
emit('renamed') emit('renamed')
}) })
}) })

View file

@ -22,10 +22,12 @@ const configColumns = [{
) )
} }
const displayName = args.text || ''
return ( return (
<div class="flex"> <div class="flex">
{renderIcon(args.record.is_dir)} {renderIcon(args.record.is_dir)}
{args.text} {displayName}
</div> </div>
) )
}, },

View file

@ -19,7 +19,7 @@ import { message } from 'ant-design-vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const name = computed(() => route.params?.name?.toString() ?? '') const name = computed(() => decodeURIComponent(route.params?.name?.toString() ?? ''))
const ngx_config: NgxConfig = reactive({ const ngx_config: NgxConfig = reactive({
name: '', name: '',
@ -151,7 +151,7 @@ async function save() {
}).then(r => { }).then(r => {
handleResponse(r) handleResponse(r)
router.push({ router.push({
path: `/sites/${filename.value}`, path: `/sites/${encodeURIComponent(filename.value)}`,
query: route.query, query: route.query,
}) })
message.success($gettext('Saved successfully')) message.success($gettext('Saved successfully'))

View file

@ -26,7 +26,7 @@ function save() {
modify.value = false modify.value = false
message.success($gettext('Renamed successfully')) message.success($gettext('Renamed successfully'))
router.push({ router.push({
path: `/sites/${buffer.value}`, path: `/sites/${encodeURIComponent(buffer.value)}`,
}) })
}).finally(() => { }).finally(() => {
loading.value = false loading.value = false

View file

@ -166,7 +166,7 @@ function handleBatchUpdated() {
}" }"
:scroll-x="1600" :scroll-x="1600"
@click-edit="(r: string) => router.push({ @click-edit="(r: string) => router.push({
path: `/sites/${r}`, path: `/sites/${encodeURIComponent(r)}`,
})" })"
@click-batch-modify="handleClickBatchEdit" @click-batch-modify="handleClickBatchEdit"
> >

View file

@ -19,11 +19,7 @@ import { message } from 'ant-design-vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const name = ref(route.params.name.toString()) const name = computed(() => decodeURIComponent(route.params?.name?.toString() ?? ''))
watch(route, () => {
name.value = route.params?.name?.toString() ?? ''
})
const ngxConfig: NgxConfig = reactive({ const ngxConfig: NgxConfig = reactive({
name: '', name: '',
@ -139,7 +135,7 @@ async function save() {
}).then(r => { }).then(r => {
handleResponse(r) handleResponse(r)
router.push({ router.push({
path: `/streams/${filename.value}`, path: `/streams/${encodeURIComponent(filename.value)}`,
query: route.query, query: route.query,
}) })
message.success($gettext('Saved successfully')) message.success($gettext('Saved successfully'))

View file

@ -26,7 +26,7 @@ function save() {
modify.value = false modify.value = false
message.success($gettext('Renamed successfully')) message.success($gettext('Renamed successfully'))
router.push({ router.push({
path: `/streams/${buffer.value}`, path: `/streams/${encodeURIComponent(buffer.value)}`,
}) })
}).finally(() => { }).finally(() => {
loading.value = false loading.value = false