Allow password entry for shared directories

This commit is contained in:
2026-10-05 17:39:39 +02:00
parent 6f0f58d2d9
commit f3738cbc4d
8 changed files with 91 additions and 24 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import TextBlock from "layout/TextBlock.svelte"; import TextBlock from "layout/TextBlock.svelte";
import Button from "layout/Button.svelte";
import { type FSNavigator } from "./FSNavigator"; import { type FSNavigator } from "./FSNavigator";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { breadcrumbs_store } from "wrap/HeaderStore"; import { breadcrumbs_store } from "wrap/HeaderStore";
@@ -17,6 +18,16 @@ onMount(() => {
}) })
let password = $state("")
let wrong_password = $state(false)
const submit_password = async (e: SubmitEvent) => {
e.preventDefault()
await nav.navigate(nav.last_requested_path, false, password)
// This page is gone if the password was right
wrong_password = true
}
</script> </script>
{#snippet breadcrumbs()} {#snippet breadcrumbs()}
@@ -29,6 +40,23 @@ onMount(() => {
<p> <p>
This page could not be found. This page could not be found.
</p> </p>
{:else if $nav.navigation_error === "password_required"}
<h1>Password required</h1>
<p>
This directory is protected with a password.
</p>
<form onsubmit={submit_password}>
<!-- Password managers take the directory for the username. Without
this they fill in the password of the visitor's Nova account. The
hidden attribute does not work, the stylesheet overrides it -->
<input type="text" autocomplete="username" value={$nav.last_requested_path.split("/")[1]} style="display: none;">
<!-- svelte-ignore a11y_autofocus -->
<input type="password" bind:value={password} placeholder="Password" required autofocus>
<Button type="submit" icon="lock_open" label="Open"/>
</form>
{#if wrong_password}
<p>This password is not correct.</p>
{/if}
{:else if $nav.navigation_error === "permission_denied" || $nav.navigation_error === "forbidden"} {:else if $nav.navigation_error === "permission_denied" || $nav.navigation_error === "forbidden"}
<h1>Permission denied</h1> <h1>Permission denied</h1>
<p> <p>

View File

@@ -45,8 +45,8 @@ export class FSNavigator {
last_requested_path: string = "" last_requested_path: string = ""
navigation_error: string = "" navigation_error: string = ""
navigate = async (path: string, push_history: boolean) => { navigate = async (path: string, push_history: boolean, password?: string) => {
if (path === this.last_requested_path) { if (path === this.last_requested_path && password === undefined) {
console.debug("FSNavigator: Requested path ", path, " is equal to current path. Debouncing") console.debug("FSNavigator: Requested path ", path, " is equal to current path. Debouncing")
return return
} }
@@ -56,6 +56,18 @@ export class FSNavigator {
path = "/" + path path = "/" + path
} }
// The password of a shared directory is entered by the visitor, or it's
// in the link they opened. It's sent in a cookie, which is the only way
// to get it into the requests for images, videos and downloads. The
// cookie's path is the directory, so each directory has its own password
if (password === undefined && this.history_enabled) {
password = new URLSearchParams(window.location.search).get("password")
}
if (password) {
document.cookie = "nova_fs_password=" + encodeURIComponent(password) +
"; path=" + fs_path_url(path.split("/")[1]) + "; samesite=lax"
}
console.debug("FSNavigator: Navigating to path", path, push_history) console.debug("FSNavigator: Navigating to path", path, push_history)
try { try {
@@ -70,9 +82,9 @@ export class FSNavigator {
this.open_node(resp, push_history) this.open_node(resp, push_history)
} catch (err: any) { } catch (err: any) {
if (err.value !== undefined && err.value === "path_not_found") { if (err.value !== undefined && err.value === "path_not_found") {
if (path !== this.path[0].path && path !== "/" && path !== "") { if (path !== this.path[0]?.path && path !== "/" && path !== "") {
console.debug("Path", path, "was not found, trying to navigate to parent") console.debug("Path", path, "was not found, trying to navigate to parent")
this.navigate(fs_split_path(path).parent, push_history) this.navigate(fs_split_path(path).parent, push_history, password)
} }
} else if (err.value !== undefined) { } else if (err.value !== undefined) {
this.navigation_error = err.value this.navigation_error = err.value

View File

@@ -58,13 +58,7 @@ onMount(() => {
const keydown = (e: KeyboardEvent) => { const keydown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.altKey || e.metaKey) { if (e.ctrlKey || e.altKey || e.metaKey) {
return // prevent custom shortcuts from interfering with system shortcuts return // prevent custom shortcuts from interfering with system shortcuts
} else if ( } else if (["text", "password", "textarea"].includes((document.activeElement as any).type)) {
(document.activeElement as any).type !== undefined &&
(
(document.activeElement as any).type === "text" ||
(document.activeElement as any).type === "textarea"
)
) {
return // Prevent shortcuts from interfering with input fields return // Prevent shortcuts from interfering with input fields
} }

View File

@@ -9,7 +9,7 @@ import { user } from "lib/UserStore";
let affiliate_link = $derived( let affiliate_link = $derived(
window.location.protocol+"//"+window.location.host + window.location.protocol+"//"+window.location.host +
"?ref=" + encodeURIComponent($user.username) "/user?ref=" + encodeURIComponent($user.username)
) )
let affiliate_deny = $state(false) let affiliate_deny = $state(false)
onMount(() => { onMount(() => {

View File

@@ -74,10 +74,10 @@ const deny = () => {
<Modal bind:this={modal} title="Affiliate sponsoring request" width="700px"> <Modal bind:this={modal} title="Affiliate sponsoring request" width="700px">
<section> <section>
<p> <p>
Hi! {referral} wants you to sponsor their Nova account. This Hi! {referral} wants you to sponsor their Nova account. This will
will give them €0.50 every month in Nova prepaid credit. They give them €0.01 every day in Nova prepaid credit. They can use this
can use this credit to get a discount on their file storage and credit to get a discount on their file storage and sharing costs.
sharing costs. Here is a short summary of what this entails: Here is a short summary of what this entails:
</p> </p>
<ul> <ul>
<li> <li>
@@ -97,10 +97,6 @@ const deny = () => {
You can change who you are sponsoring at any time on your <a You can change who you are sponsoring at any time on your <a
href="/user/settings">account settings page</a>. href="/user/settings">account settings page</a>.
</li> </li>
<li>
If you want to know more about the affiliate program check out
the <a href="/about#toc_12">Q&A page</a>.
</li>
</ul> </ul>
<p> <p>
If you click 'Accept' then the requested affiliate code will be If you click 'Accept' then the requested affiliate code will be

View File

@@ -32,8 +32,13 @@ func (wc *WebController) serveDirectory(w http.ResponseWriter, r *http.Request,
td.User = json.RawMessage("null") td.User = json.RawMessage("null")
} }
// A shared link can have the password of the directory in it
var path = strings.TrimPrefix(p.ByName("path"), "/") var path = strings.TrimPrefix(p.ByName("path"), "/")
var err = pdapi.GetJSON("filesystem/"+url.PathEscape(path)+"?stat", &td.Node) var err = pdapi.GetJSON(
"filesystem/"+url.PathEscape(path)+"?stat&password="+
url.QueryEscape(r.URL.Query().Get("password")),
&td.Node,
)
if apiErr, ok := errors.AsType[pixelapi.Error](err); ok { if apiErr, ok := errors.AsType[pixelapi.Error](err); ok {
// Set the proper response code for the error message // Set the proper response code for the error message
switch apiErr.StatusCode { switch apiErr.StatusCode {

View File

@@ -124,12 +124,20 @@ func (wc *WebController) metadataFromFilesystem(r *http.Request, f api.Filesyste
} }
} }
// The services which make link previews don't keep cookies, so a password
// in the link has to be in every URL they open
var query, thumbnail = "", "?thumbnail"
if password := r.URL.Query().Get("password"); password != "" {
password = "password=" + url.QueryEscape(password)
query, thumbnail = "?"+password, thumbnail+"&"+password
}
return generateOGData( return generateOGData(
base.Name, base.Name,
base.FileType, base.FileType,
addr+"/d"+filepath, addr+"/d"+filepath+query,
addr+"/api/filesystem"+filepath, addr+"/api/filesystem"+filepath+query,
addr+"/api/filesystem"+filepath+"?thumbnail", addr+"/api/filesystem"+filepath+thumbnail,
colour, colour,
) )
} }

View File

@@ -0,0 +1,24 @@
package webcontroller
import (
"net/http/httptest"
"strings"
"testing"
"fornaxian.tech/nova_server/api"
)
// The services which make link previews have no cookies, so a password in the
// link has to be in the URLs they open
func TestMetadataPassword(t *testing.T) {
var r = httptest.NewRequest("GET", "/d/abc/a%20b.png?password=p%26w+1", nil)
var og = (&WebController{}).metadataFromFilesystem(r, api.FilesystemPath{
Path: []api.FilesystemNode{{Path: "/abc/a b.png", FileType: "image/png"}},
})
for _, prop := range og.MetaPropRules {
if (prop.Key == "og:url" || prop.Key == "og:image") &&
!strings.HasSuffix(prop.Value, "/abc/a%20b.png?password=p%26w+1") {
t.Errorf("%s is %s", prop.Key, prop.Value)
}
}
}