Calculate folder tree

This commit is contained in:
Melon 2023-11-20 16:33:42 +00:00
parent d950eca59f
commit a7a0008eb1
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
9 changed files with 227 additions and 28 deletions

View File

@ -1,3 +1,4 @@
VITE_SSO_ORIGIN=http://localhost:9090
VITE_API_LOTUS=http://localhost:9095/v1/lotus
VITE_IMAP_LOTUS=ws://localhost:9095/v1/lotus/imap

View File

@ -1,3 +1,4 @@
VITE_SSO_ORIGIN=https://sso.1f349.com
VITE_API_LOTUS=https://api.1f349.com/v1/lotus
VITE_IMAP_LOTUS=wss://api.1f349.com/v1/lotus/imap

View File

@ -1,20 +1,23 @@
<script lang="ts">
import {onMount} from "svelte";
import {getBearer, loginStore} from "./stores/login";
import {openLoginPopup} from "./utils/login-popup";
import type {ImapFolder} from "./types/imap";
import type {TreeFolder, RootFolder} from "./types/internal";
let mainWS: WebSocket;
$: window.mainWS = mainWS;
interface FolderValue {
Attributes: string[];
Delimiter: string;
Name: string;
let folders: RootFolder[] = [];
function countChar(s: string, c: string) {
let result = 0;
for (let i = 0; i < s.length; i++) if (s[i] == c) result++;
return result;
}
let folders: FolderValue[] = [];
function connectWS() {
mainWS = new WebSocket("wss://api.1f349.com/v1/lotus/imap");
mainWS = new WebSocket(import.meta.env.VITE_IMAP_LOTUS);
mainWS.addEventListener("open", () => {
mainWS.send(JSON.stringify({token: getBearer().slice(7)}));
});
@ -24,24 +27,119 @@
mainWS.send(JSON.stringify({action: "list", args: ["", "*"]}));
}
if (j.type === "list") {
folders = j.value;
folders.sort((a, b) => a.Name.localeCompare(b.Name));
// === Example output of list command ===
// let j = {
// type: "list",
// value: [
// {Attributes: ["\\HasChildren", "\\UnMarked", "\\Archive"], Delimiter: "/", Name: "Archive"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Junk"], Delimiter: "/", Name: "Junk"},
// {Attributes: ["\\HasChildren", "\\Trash"], Delimiter: "/", Name: "Trash"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/status"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hello"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hi"},
// {Attributes: ["\\Noselect", "\\HasChildren"], Delimiter: "/", Name: "INBOX/sub"},
// {Attributes: ["\\HasNoChildren"], Delimiter: "/", Name: "INBOX/sub/folder"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Drafts"], Delimiter: "/", Name: "Drafts"},
// {Attributes: ["\\HasNoChildren", "\\Sent"], Delimiter: "/", Name: "Sent"},
// {Attributes: ["\\HasChildren"], Delimiter: "/", Name: "INBOX"},
// ],
// };
let imapFolders = j.value as ImapFolder[];
// Remove no-select folders
imapFolders = imapFolders.filter(x => !x.Attributes.includes("\\\\Noselect"));
// Sort shorter paths first so parent folders are registered before children
imapFolders = imapFolders.sort((a, b) => countChar(a.Name, a.Delimiter) - countChar(b.Name, b.Delimiter));
// Setup root folders
let INBOX: RootFolder = {role: "Inbox", name: "Inbox", attr: new Set(), children: []};
let DRAFTS: RootFolder = {role: "Drafts", name: "Drafts", attr: new Set(["\\\\Drafts"]), children: []};
let SENT: RootFolder = {role: "Sent", name: "Sent", attr: new Set(["\\\\Sent"]), children: []};
let ARCHIVE: RootFolder = {role: "Archive", name: "Archive", attr: new Set(["\\\\Archive"]), children: []};
let JUNK: RootFolder = {role: "Junk", name: "Junk", attr: new Set(["\\\\Junk"]), children: []};
let TRASH: RootFolder = {role: "Trash", name: "Trash", attr: new Set(["\\\\Trash"]), children: []};
// Setup map to find special folders
let ROOT: Map<string, RootFolder> = new Map();
ROOT.set("Drafts", DRAFTS);
ROOT.set("Sent", SENT);
ROOT.set("Archive", ARCHIVE);
ROOT.set("Junk", JUNK);
ROOT.set("Trash", TRASH);
// Store reference to special folders
let ROOTKEYS: Map<string, RootFolder> = new Map();
imapFolders.forEach(x => {
// Find inbox folder
if (x.Name === "INBOX") {
x.Attributes.forEach(x => {
INBOX.attr.add(x);
});
ROOTKEYS.set(x.Name, INBOX);
return; // continue imapFolders loop
}
// Test for all special folder attributes
for (let [k, v] of ROOT.entries()) {
if (x.Attributes.includes("\\" + k)) {
v.name = x.Name;
x.Attributes.forEach(x => {
v.attr.add(x);
});
// map name to root key
ROOTKEYS.set(x.Name, v);
return; // continue imapFolders loop
}
}
let n = x.Name.indexOf(x.Delimiter);
if (n == -1) {
console.error("No parent folder wtf??", x.Name);
return;
}
let parent = x.Name.slice(0, n);
let pObj: TreeFolder | undefined = ROOTKEYS.get(parent);
if (pObj == undefined) {
console.error("Parent is not a root folder??", x.Name);
return;
}
let pIdx = n + 1;
for (let i = pIdx; i < x.Name.length; i++) {
if (x.Name[i] != x.Delimiter) continue;
// find child matching current slice
let nextObj: TreeFolder | undefined = pObj?.children.find(x2 => {
// check if folder matches current slice
return x2.name === x.Name.slice(pIdx, i);
});
// if no slice is found try a bigger slice
if (nextObj == undefined) continue;
// move into child folder
pObj = nextObj;
pIdx = i + 1;
}
// no parent was found at all
if (pObj == undefined) {
console.error("Parent folder does not exist??", x.Name);
return;
}
// add child to current parent
pObj.children.push({
name: x.Name.slice(pIdx),
attr: new Set(x.Attributes),
children: [],
});
});
// output special folders in order
folders = [INBOX, DRAFTS, SENT, ARCHIVE, JUNK, TRASH];
}
// let a = {
// type: "list",
// value: [
// {Attributes: ["\\HasChildren", "\\UnMarked", "\\Archive"], Delimiter: "/", Name: "Archive"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Junk"], Delimiter: "/", Name: "Junk"},
// {Attributes: ["\\HasChildren", "\\Trash"], Delimiter: "/", Name: "Trash"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/status"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hello"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hi"},
// {Attributes: ["\\Noselect", "\\HasChildren"], Delimiter: "/", Name: "INBOX/this"},
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Drafts"], Delimiter: "/", Name: "Drafts"},
// {Attributes: ["\\HasNoChildren", "\\Sent"], Delimiter: "/", Name: "Sent"},
// {Attributes: ["\\HasChildren"], Delimiter: "/", Name: "INBOX"},
// ],
// };
});
}
@ -49,6 +147,10 @@
$loginStore = null;
localStorage.removeItem("login-session");
}
onMount(() => {
connectWS();
});
</script>
<header>
@ -77,12 +179,18 @@
{:else}
<div id="sidebar">
{#each folders as folder}
<button class:selected={folder.Name === "INBOX"}>{folder.Name}</button>
<button class:selected={folder.name === "INBOX"}>{folder.name}</button>
<div>{folder.children.length}</div>
{/each}
</div>
<div id="option-view">
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
<button on:click={() => connectWS()}>Connect WS</button>
<div>
<code>
<pre>{JSON.stringify(folders, null, 2)}</pre>
</code>
</div>
</div>
{/if}
</main>

5
src/types/imap.ts Normal file
View File

@ -0,0 +1,5 @@
export interface ImapFolder {
Attributes: string[];
Delimiter: string;
Name: string;
}

9
src/types/internal.ts Normal file
View File

@ -0,0 +1,9 @@
export interface TreeFolder {
name: string;
attr: Set<string>;
children: TreeFolder[];
}
export interface RootFolder extends TreeFolder {
role: string;
}

1
src/vite-env.d.ts vendored
View File

@ -4,6 +4,7 @@
interface ImportMetaEnv {
VITE_SSO_ORIGIN: string;
VITE_API_LOTUS: string;
VITE_IMAP_LOTUS: string;
}
interface ImportMeta {

View File

@ -6,11 +6,13 @@ require (
github.com/1f349/mjwt v0.2.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/google/uuid v1.4.0
github.com/gorilla/websocket v1.5.1
github.com/rs/cors v1.10.1
)
require (
github.com/becheran/wildmatch-go v1.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/net v0.17.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -8,6 +8,8 @@ github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOW
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -16,6 +18,8 @@ github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -6,6 +6,7 @@ import (
"fmt"
"log"
"net/http"
"slices"
"strings"
"time"
@ -14,9 +15,16 @@ import (
"github.com/1f349/mjwt/claims"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/rs/cors"
)
var wsUpgrade = &websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func main() {
log.Println("Starting test server")
signer, err := mjwt.NewMJwtSignerFromFileOrCreate("Test SSO Service", "private.key.local", rand.Reader, 2048)
@ -34,7 +42,7 @@ func ssoServer(signer mjwt.Signer) {
r := http.NewServeMux()
r.HandleFunc("/popup", func(w http.ResponseWriter, r *http.Request) {
ps := claims.NewPermStorage()
ps.Set("mail-client")
ps.Set("mail:inbox=admin@localhost")
accessToken, err := signer.GenerateJwt("81b99bd7-bf74-4cc2-9133-80ed2393dfe6", uuid.NewString(), jwt.ClaimStrings{"d0555671-df9d-42d0-a4d6-94b694251f0b"}, 15*time.Minute, auth.AccessTokenClaims{
Perms: ps,
})
@ -123,13 +131,73 @@ func apiServer(verify mjwt.Verifier) {
}
json.NewEncoder(rw).Encode(m)
}))
r.HandleFunc("/v1/lotus/imap", func(rw http.ResponseWriter, req *http.Request) {
c, err := wsUpgrade.Upgrade(rw, req, nil)
if err != nil {
log.Println("WebSocket upgrade error:", err)
return
}
defer c.Close()
for {
var m map[string]any
err = c.ReadJSON(&m)
if err != nil {
log.Println("WebSocket json error:", err)
return
}
if v, ok := m["token"]; ok {
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](verify, v.(string))
if err != nil {
c.WriteMessage(websocket.TextMessage, []byte("Invalid token"))
return
}
b2 := b.Claims.Perms.Search("mail:inbox=*")
if len(b2) != 1 {
c.WriteMessage(websocket.TextMessage, []byte("Invalid mail inbox perm"))
return
}
c.WriteMessage(websocket.TextMessage, []byte(`{"auth":"ok"}`))
continue
} else if vAct, ok := m["action"]; ok {
switch vAct.(string) {
case "list":
log.Println(m)
if slices.EqualFunc[[]any, []any, any, any](m["args"].([]any), []any{"", "*"}, func(a1, a2 any) bool {
return a1 == a2
}) {
c.WriteMessage(websocket.TextMessage, []byte(`
{
"type": "list",
"value": [
{"Attributes": ["\\HasChildren", "\\UnMarked", "\\Archive"], "Delimiter": "/", "Name": "Archive"},
{"Attributes": ["\\HasNoChildren", "\\UnMarked"], "Delimiter": "/", "Name": "Archive/2022"},
{"Attributes": ["\\HasNoChildren", "\\UnMarked"], "Delimiter": "/", "Name": "Archive/2023"},
{"Attributes": ["\\HasNoChildren", "\\UnMarked", "\\Junk"], "Delimiter": "/", "Name": "Junk"},
{"Attributes": ["\\HasChildren", "\\Trash"], "Delimiter": "/", "Name": "Trash"},
{"Attributes": ["\\HasNoChildren", "\\UnMarked"], "Delimiter": "/", "Name": "INBOX/status"},
{"Attributes": ["\\HasNoChildren", "\\UnMarked"], "Delimiter": "/", "Name": "INBOX/hello"},
{"Attributes": ["\\HasNoChildren", "\\UnMarked"], "Delimiter": "/", "Name": "INBOX/hi"},
{"Attributes": ["\\Noselect", "\\HasChildren"], "Delimiter": "/", "Name": "INBOX/test/sub/folder"},
{"Attributes": ["\\HasNoChildren"], "Delimiter": "/", "Name": "INBOX/test/sub/folder/something"},
{"Attributes": ["\\HasNoChildren", "\\UnMarked", "\\Drafts"], "Delimiter": "/", "Name": "Drafts"},
{"Attributes": ["\\HasNoChildren", "\\Sent"], "Delimiter": "/", "Name": "Sent"},
{"Attributes": ["\\HasChildren"], "Delimiter": "/", "Name": "INBOX"}
]
}
`))
}
continue
}
}
}
})
logger := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
log.Println("[API Server]", req.URL.String())
r.ServeHTTP(rw, req)
})
http.ListenAndServe(":9095", serveApiCors.Handler(logger))
log.Println("[API Server]", http.ListenAndServe(":9090", r))
log.Println("[API Server]", http.ListenAndServe(":9095", serveApiCors.Handler(logger)))
}
func hasPerm(verify mjwt.Verifier, perm string, next func(rw http.ResponseWriter, req *http.Request)) http.Handler {