@studious-lms/server 1.8.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +3 -3
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/agenda.d.ts +2 -2
- package/dist/models/announcement.d.ts +2 -2
- package/dist/models/assignment.d.ts +33 -33
- package/dist/models/class.d.ts +2 -2
- package/dist/models/cloudStorage.d.ts +14 -1
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/cloudStorage.js +123 -3
- package/dist/models/cloudStorage.js.map +1 -1
- package/dist/models/event.d.ts +3 -3
- package/dist/models/file.d.ts +27 -26
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/file.js +4 -39
- package/dist/models/file.js.map +1 -1
- package/dist/models/labChat.d.ts +6 -6
- package/dist/models/section.d.ts +1 -1
- package/dist/routers/_app.d.ts +444 -100
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/agenda.d.ts +2 -2
- package/dist/routers/assignment.d.ts +31 -31
- package/dist/routers/class.d.ts +5 -5
- package/dist/routers/event.d.ts +2 -2
- package/dist/routers/file.d.ts +2 -2
- package/dist/routers/folder.d.ts +24 -0
- package/dist/routers/folder.d.ts.map +1 -1
- package/dist/routers/folder.js +27 -5
- package/dist/routers/folder.js.map +1 -1
- package/dist/routers/labChat.d.ts +2 -2
- package/dist/routers/newtonChat.d.ts +1 -1
- package/dist/routers/permissions.d.ts +120 -0
- package/dist/routers/permissions.d.ts.map +1 -0
- package/dist/routers/permissions.js +50 -0
- package/dist/routers/permissions.js.map +1 -0
- package/dist/routers/studentProgress.d.ts +45 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +12 -3
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routers/tag.d.ts +5 -5
- package/dist/services/agenda.d.ts +2 -2
- package/dist/services/assignment.d.ts +31 -31
- package/dist/services/class.d.ts +5 -5
- package/dist/services/class.js +3 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/event.d.ts +2 -2
- package/dist/services/file.d.ts +22 -5
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +81 -45
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +97 -2
- package/dist/services/folder.d.ts.map +1 -1
- package/dist/services/folder.js +158 -87
- package/dist/services/folder.js.map +1 -1
- package/dist/services/labChat.d.ts +2 -2
- package/dist/services/permissions.d.ts +188 -0
- package/dist/services/permissions.d.ts.map +1 -0
- package/dist/services/permissions.js +661 -0
- package/dist/services/permissions.js.map +1 -0
- package/dist/services/studentProgress.d.ts +94 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +94 -3
- package/dist/services/studentProgress.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
- package/prisma/schema.prisma +74 -0
- package/src/middleware/security.ts +1 -1
- package/src/models/cloudStorage.ts +134 -1
- package/src/models/file.ts +2 -37
- package/src/routers/_app.ts +2 -0
- package/src/routers/folder.ts +32 -2
- package/src/routers/permissions.ts +71 -0
- package/src/routers/studentProgress.ts +21 -0
- package/src/services/class.ts +1 -1
- package/src/services/file.ts +76 -47
- package/src/services/folder.ts +180 -91
- package/src/services/permissions.ts +778 -0
- package/src/services/studentProgress.ts +197 -11
- package/tests/unit/services/permissions.test.ts +463 -0
- package/tests/unit/services/studentProgress.test.ts +168 -2
package/src/services/file.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* File service – signed URLs, move, rename, delete.
|
|
3
|
-
* for assignment, submission, announcement, folder, and user-owned files.
|
|
2
|
+
* File service – signed URLs, move, rename, delete.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Two access concepts coexist here, and they should not be conflated:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Contextual access** — assignment, submission, announcement, and chat
|
|
7
|
+
* attachments. A teacher viewing a student's submission has access by
|
|
8
|
+
* virtue of teaching the class, not because anyone explicitly "shared"
|
|
9
|
+
* the file with them. These checks live inline in `getFileSignedUrl`
|
|
10
|
+
* below and predate the explicit-share system.
|
|
11
|
+
*
|
|
12
|
+
* 2. **Explicit-share access** — folder-scoped files in cloud storage.
|
|
13
|
+
* Permission flows through the central resolver in `permissions.ts`,
|
|
14
|
+
* which consults explicit `Permission` rows in addition to the
|
|
15
|
+
* scope-derived defaults. The cloud-storage helpers (`*Scoped` /
|
|
16
|
+
* `*Generic`) below all use this path.
|
|
17
|
+
*
|
|
18
|
+
* If you're tempted to refactor the contextual checks into the resolver,
|
|
19
|
+
* don't — they intentionally key off the file's domain context, not its
|
|
20
|
+
* filesystem location.
|
|
9
21
|
*/
|
|
10
22
|
import { TRPCError } from "@trpc/server";
|
|
11
23
|
import { getSignedUrl, deleteFile } from "../lib/googleCloudStorage.js";
|
|
@@ -23,17 +35,34 @@ import { findConversationMembership } from "../models/message.js";
|
|
|
23
35
|
import { prisma } from "../lib/prisma.js";
|
|
24
36
|
import { resolveFolderScope } from "../models/cloudStorage.js";
|
|
25
37
|
import { requireFolderAccess } from "./folder.js";
|
|
38
|
+
import { requireFileAccess } from "./permissions.js";
|
|
26
39
|
|
|
27
40
|
function isSchoolAdminForClass(classData: any, userId: string) {
|
|
28
41
|
return Boolean(
|
|
29
42
|
classData?.school?.memberships?.some(
|
|
30
43
|
(membership: { userId: string }) => membership.userId === userId
|
|
31
|
-
)
|
|
32
|
-
classData?.school?.users?.some((admin: { id: string }) => admin.id === userId)
|
|
44
|
+
)
|
|
33
45
|
);
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Generate a signed URL for file download.
|
|
50
|
+
*
|
|
51
|
+
* Access resolution, in order — first match wins:
|
|
52
|
+
* 1. Owner of the file.
|
|
53
|
+
* 2. Member of the conversation the file is attached to (chat upload).
|
|
54
|
+
* 3. Contextual access via the file's class context: assignment / announcement
|
|
55
|
+
* / submission / annotations attached to a class the user teaches, learns
|
|
56
|
+
* in, or is a school admin for.
|
|
57
|
+
* 4. The central permissions resolver, which covers folder-scoped files —
|
|
58
|
+
* explicit Permission grants (USER / TAG / CLASS / DEPARTMENT / SCHOOL
|
|
59
|
+
* principals) plus scope defaults (school members read SCHOOL_STORAGE,
|
|
60
|
+
* class members read class folders, owners read personal folders).
|
|
61
|
+
*
|
|
62
|
+
* The resolver is invoked unconditionally as the last step, so files shared
|
|
63
|
+
* via the new Permission system reach this code path even when they have no
|
|
64
|
+
* matching domain context.
|
|
65
|
+
*/
|
|
37
66
|
export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
38
67
|
const file = await findFileById(fileId);
|
|
39
68
|
if (!file) {
|
|
@@ -45,58 +74,58 @@ export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
|
45
74
|
|
|
46
75
|
let hasAccess = false;
|
|
47
76
|
|
|
48
|
-
if (file.
|
|
49
|
-
|
|
50
|
-
(t) => t.id === userId
|
|
51
|
-
);
|
|
52
|
-
const isStudent = file.assignment.class.students.some(
|
|
53
|
-
(s) => s.id === userId
|
|
54
|
-
);
|
|
55
|
-
const isSchoolAdmin = isSchoolAdminForClass(file.assignment.class, userId);
|
|
56
|
-
logger.info(
|
|
57
|
-
`Assignment file access check - userId: ${userId}, isTeacher: ${isTeacher}, isStudent: ${isStudent}, isSchoolAdmin: ${isSchoolAdmin}`
|
|
58
|
-
);
|
|
59
|
-
hasAccess = isTeacher || isStudent || isSchoolAdmin;
|
|
77
|
+
if (file.userId === userId) {
|
|
78
|
+
hasAccess = true;
|
|
60
79
|
}
|
|
61
80
|
|
|
62
|
-
if (
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const isStudent = ann.class.students.some((s: any) => s.id === userId);
|
|
66
|
-
const isSchoolAdmin = isSchoolAdminForClass(ann.class, userId);
|
|
67
|
-
hasAccess = hasAccess || isTeacher || isStudent || isSchoolAdmin;
|
|
81
|
+
if (!hasAccess && file.conversationId) {
|
|
82
|
+
const membership = await findConversationMembership(file.conversationId, userId);
|
|
83
|
+
hasAccess = Boolean(membership);
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
if (file.
|
|
86
|
+
if (!hasAccess && file.assignment?.class) {
|
|
87
|
+
const cls = file.assignment.class;
|
|
71
88
|
hasAccess =
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
isSchoolAdminForClass(file.submission.assignment.class, userId);
|
|
89
|
+
cls.teachers.some((t) => t.id === userId) ||
|
|
90
|
+
cls.students.some((s) => s.id === userId) ||
|
|
91
|
+
isSchoolAdminForClass(cls, userId);
|
|
76
92
|
}
|
|
77
93
|
|
|
78
|
-
if (file.
|
|
94
|
+
if (!hasAccess && (file as any).announcement?.class) {
|
|
95
|
+
const cls = (file as any).announcement.class;
|
|
79
96
|
hasAccess =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
isSchoolAdminForClass(file.annotations.assignment.class, userId);
|
|
97
|
+
cls.teachers.some((t: { id: string }) => t.id === userId) ||
|
|
98
|
+
cls.students.some((s: { id: string }) => s.id === userId) ||
|
|
99
|
+
isSchoolAdminForClass(cls, userId);
|
|
84
100
|
}
|
|
85
101
|
|
|
86
|
-
if (file.
|
|
87
|
-
|
|
102
|
+
if (!hasAccess && file.submission?.assignment?.classId) {
|
|
103
|
+
const cls = file.submission.assignment.class;
|
|
104
|
+
hasAccess =
|
|
105
|
+
file.submission.studentId === userId ||
|
|
106
|
+
cls.teachers.some((t) => t.id === userId) ||
|
|
107
|
+
isSchoolAdminForClass(cls, userId);
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
if (file.
|
|
91
|
-
const
|
|
92
|
-
hasAccess =
|
|
110
|
+
if (!hasAccess && file.annotations?.assignment?.classId) {
|
|
111
|
+
const cls = file.annotations.assignment.class;
|
|
112
|
+
hasAccess =
|
|
113
|
+
file.annotations.studentId === userId ||
|
|
114
|
+
cls.teachers.some((t) => t.id === userId) ||
|
|
115
|
+
isSchoolAdminForClass(cls, userId);
|
|
93
116
|
}
|
|
94
117
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
// Final fallback: the explicit-share + scope-default resolver. Covers
|
|
119
|
+
// folder-scoped files shared via Permission grants (incl. SCHOOL/CLASS/TAG
|
|
120
|
+
// principals) and any institution member reading SCHOOL_STORAGE files.
|
|
121
|
+
// Safe to call for unfiled files — the resolver returns owner-only there.
|
|
122
|
+
if (!hasAccess) {
|
|
123
|
+
try {
|
|
124
|
+
await requireFileAccess(userId, fileId, "READ");
|
|
125
|
+
hasAccess = true;
|
|
126
|
+
} catch {
|
|
127
|
+
// resolver denied — fall through to FORBIDDEN below.
|
|
128
|
+
}
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
if (!hasAccess) {
|
package/src/services/folder.ts
CHANGED
|
@@ -28,106 +28,107 @@ import {
|
|
|
28
28
|
type DirectFileData,
|
|
29
29
|
confirmDirectUpload,
|
|
30
30
|
} from "../lib/fileUpload.js";
|
|
31
|
-
|
|
32
|
-
type AccessLevel
|
|
31
|
+
import {
|
|
32
|
+
type AccessLevel,
|
|
33
|
+
getScopeDefault,
|
|
34
|
+
listSharedWithMe,
|
|
35
|
+
requireFolderAccess as requireFolderAccessResolved,
|
|
36
|
+
} from "./permissions.js";
|
|
33
37
|
|
|
34
38
|
interface AccessResult {
|
|
35
39
|
scope: CloudScope;
|
|
36
40
|
canWrite: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* True when the viewer's access comes through an explicit grant rather than
|
|
43
|
+
* scope membership. Used by `browseFolder` to surface a "Shared with me"
|
|
44
|
+
* heading instead of the underlying scope's title — the grantee shouldn't
|
|
45
|
+
* see the folder labelled with the owner's scope.
|
|
46
|
+
*/
|
|
47
|
+
viaGrant: boolean;
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Scope-default-only check: used when there is no folder id yet (browsing or
|
|
52
|
+
* creating at the scope root). Does *not* consult explicit grants — those only
|
|
53
|
+
* apply to specific folders. Explicit-grant access to sub-folders surfaces via
|
|
54
|
+
* the "Shared with me" virtual scope, not by being a scope member.
|
|
55
|
+
*/
|
|
39
56
|
async function requireScopeAccess(
|
|
40
57
|
userId: string,
|
|
41
58
|
scope: CloudScope,
|
|
42
59
|
level: AccessLevel
|
|
43
60
|
): Promise<AccessResult> {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
teachers: { where: { id: userId }, select: { id: true } },
|
|
52
|
-
students: { where: { id: userId }, select: { id: true } },
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
if (!classData) {
|
|
56
|
-
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
57
|
-
}
|
|
58
|
-
const isTeacher = classData.teachers.length > 0;
|
|
59
|
-
const isStudent = classData.students.length > 0;
|
|
60
|
-
const isSchoolAdmin = classData.schoolId
|
|
61
|
-
? Boolean(
|
|
62
|
-
await prisma.schoolMembership.findFirst({
|
|
63
|
-
where: {
|
|
64
|
-
schoolId: classData.schoolId,
|
|
65
|
-
userId,
|
|
66
|
-
role: "ADMIN",
|
|
67
|
-
status: "ACTIVE",
|
|
68
|
-
},
|
|
69
|
-
select: { id: true },
|
|
70
|
-
})
|
|
71
|
-
)
|
|
72
|
-
: false;
|
|
73
|
-
if (!isTeacher && !isStudent && !isSchoolAdmin) {
|
|
74
|
-
throw new TRPCError({
|
|
75
|
-
code: "FORBIDDEN",
|
|
76
|
-
message: "You don't have access to this class",
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
const canWrite = isTeacher || isSchoolAdmin;
|
|
80
|
-
if (level === "WRITE" && !canWrite) {
|
|
81
|
-
throw new TRPCError({
|
|
82
|
-
code: "FORBIDDEN",
|
|
83
|
-
message: "Only class teachers can modify class files",
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
return { scope, canWrite };
|
|
87
|
-
}
|
|
88
|
-
case "SCHOOL_STORAGE": {
|
|
89
|
-
const membership = await prisma.schoolMembership.findFirst({
|
|
90
|
-
where: { schoolId: scope.schoolId, userId, status: "ACTIVE" },
|
|
91
|
-
select: { role: true },
|
|
92
|
-
});
|
|
93
|
-
if (!membership) {
|
|
94
|
-
throw new TRPCError({
|
|
95
|
-
code: "FORBIDDEN",
|
|
96
|
-
message: "You aren't a member of this institution",
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
const canWrite =
|
|
100
|
-
membership.role === "ADMIN" || membership.role === "TEACHER";
|
|
101
|
-
if (level === "WRITE" && !canWrite) {
|
|
102
|
-
throw new TRPCError({
|
|
103
|
-
code: "FORBIDDEN",
|
|
104
|
-
message: "Only teachers and admins can modify institution storage",
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return { scope, canWrite };
|
|
108
|
-
}
|
|
109
|
-
case "PERSONAL": {
|
|
110
|
-
if (scope.userId !== userId) {
|
|
111
|
-
throw new TRPCError({
|
|
112
|
-
code: "FORBIDDEN",
|
|
113
|
-
message: "You can only access your own personal storage",
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
return { scope, canWrite: true };
|
|
61
|
+
if (scope.kind === "CLASS") {
|
|
62
|
+
const classExists = await prisma.class.findFirst({
|
|
63
|
+
where: { id: scope.classId },
|
|
64
|
+
select: { id: true },
|
|
65
|
+
});
|
|
66
|
+
if (!classExists) {
|
|
67
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
117
68
|
}
|
|
118
69
|
}
|
|
70
|
+
const granted = await getScopeDefault(userId, scope);
|
|
71
|
+
if (!granted) {
|
|
72
|
+
throw new TRPCError({
|
|
73
|
+
code: "FORBIDDEN",
|
|
74
|
+
message:
|
|
75
|
+
scope.kind === "CLASS"
|
|
76
|
+
? "You don't have access to this class"
|
|
77
|
+
: scope.kind === "SCHOOL_STORAGE"
|
|
78
|
+
? "You aren't a member of this institution"
|
|
79
|
+
: "You can only access your own personal storage",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (level === "WRITE" && granted !== "WRITE") {
|
|
83
|
+
throw new TRPCError({
|
|
84
|
+
code: "FORBIDDEN",
|
|
85
|
+
message:
|
|
86
|
+
scope.kind === "CLASS"
|
|
87
|
+
? "Only class teachers can modify class files"
|
|
88
|
+
: "Only teachers and admins can modify institution storage",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Scope-default-only path; access cannot be via a grant here.
|
|
92
|
+
return { scope, canWrite: granted === "WRITE", viaGrant: false };
|
|
119
93
|
}
|
|
120
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Folder-level access check. Delegates to the central resolver which consults
|
|
97
|
+
* explicit grants in addition to the scope default. Returns the scope and a
|
|
98
|
+
* canWrite flag indicating whether the user has WRITE here (used by the UI to
|
|
99
|
+
* gate the "New folder" / "Upload" buttons during a READ-level browse).
|
|
100
|
+
*
|
|
101
|
+
* Pass `mutatesSelf: true` for actions that target the folder itself (rename,
|
|
102
|
+
* delete, move-this-folder). Drive-style: explicit grants don't authorize
|
|
103
|
+
* those — only scope-default WRITE does. Mutations of children should NOT
|
|
104
|
+
* pass this flag.
|
|
105
|
+
*/
|
|
121
106
|
export async function requireFolderAccess(
|
|
122
107
|
userId: string,
|
|
123
108
|
folderId: string,
|
|
124
|
-
level: AccessLevel
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
109
|
+
level: AccessLevel,
|
|
110
|
+
opts: { mutatesSelf?: boolean } = {}
|
|
111
|
+
): Promise<AccessResult> {
|
|
112
|
+
const result = await requireFolderAccessResolved(userId, folderId, level, opts);
|
|
113
|
+
if (!result.scope) {
|
|
114
|
+
// resolveFolderScope returns null only for missing folders; the resolver
|
|
115
|
+
// would have already thrown NOT_FOUND in that case.
|
|
128
116
|
throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
|
|
129
117
|
}
|
|
130
|
-
|
|
118
|
+
let canWrite = result.scopeDefault === "WRITE";
|
|
119
|
+
if (!canWrite && level !== "WRITE") {
|
|
120
|
+
// We came in with READ; check whether the user *also* has WRITE so the UI
|
|
121
|
+
// can show the right affordances. Cheaper to do this only when needed.
|
|
122
|
+
try {
|
|
123
|
+
await requireFolderAccessResolved(userId, folderId, "WRITE");
|
|
124
|
+
canWrite = true;
|
|
125
|
+
} catch {
|
|
126
|
+
canWrite = false;
|
|
127
|
+
}
|
|
128
|
+
} else if (level === "WRITE") {
|
|
129
|
+
canWrite = true;
|
|
130
|
+
}
|
|
131
|
+
return { scope: result.scope, canWrite, viaGrant: result.viaGrant };
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
function scopesEqual(a: CloudScope, b: CloudScope): boolean {
|
|
@@ -149,24 +150,30 @@ export async function browseFolder(
|
|
|
149
150
|
) {
|
|
150
151
|
let scope: CloudScope;
|
|
151
152
|
let folderId: string | undefined;
|
|
153
|
+
let canWrite: boolean;
|
|
154
|
+
let viaGrant: boolean;
|
|
152
155
|
|
|
153
156
|
if ("folderId" in input) {
|
|
154
157
|
folderId = input.folderId;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
scope =
|
|
158
|
+
// `requireFolderAccess` consults explicit grants in addition to the scope
|
|
159
|
+
// default, so users can drill into folders shared with them via "Shared
|
|
160
|
+
// with me" without being scope members.
|
|
161
|
+
const access = await requireFolderAccess(userId, folderId, "READ");
|
|
162
|
+
scope = access.scope;
|
|
163
|
+
canWrite = access.canWrite;
|
|
164
|
+
viaGrant = access.viaGrant;
|
|
160
165
|
} else {
|
|
161
166
|
scope = input.scope;
|
|
167
|
+
const access = await requireScopeAccess(userId, scope, "READ");
|
|
168
|
+
canWrite = access.canWrite;
|
|
169
|
+
viaGrant = false;
|
|
162
170
|
}
|
|
163
171
|
|
|
164
|
-
const access = await requireScopeAccess(userId, scope, "READ");
|
|
165
172
|
const targetId = folderId ?? (await findOrCreateScopeRoot(scope)).id;
|
|
166
173
|
|
|
167
174
|
const [folder, breadcrumbs] = await Promise.all([
|
|
168
175
|
findFolderWithContents(targetId),
|
|
169
|
-
listFolderBreadcrumbs(targetId),
|
|
176
|
+
listFolderBreadcrumbs(targetId, userId),
|
|
170
177
|
]);
|
|
171
178
|
|
|
172
179
|
if (!folder) {
|
|
@@ -176,8 +183,70 @@ export async function browseFolder(
|
|
|
176
183
|
return {
|
|
177
184
|
folder,
|
|
178
185
|
breadcrumbs,
|
|
179
|
-
canWrite
|
|
186
|
+
canWrite,
|
|
180
187
|
scope,
|
|
188
|
+
/**
|
|
189
|
+
* When the viewer's access comes through an explicit grant (not scope
|
|
190
|
+
* membership), the UI should label the root as "Shared with me" rather
|
|
191
|
+
* than the underlying scope's title. The grantee may not even be a
|
|
192
|
+
* member of that scope — showing "My Files" or "Class Files" would be
|
|
193
|
+
* misleading. Null when the viewer is a regular scope member.
|
|
194
|
+
*/
|
|
195
|
+
displayRoot: viaGrant
|
|
196
|
+
? { kind: "SHARED_WITH_ME" as const, name: "Shared with me" }
|
|
197
|
+
: null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Drive-style "Shared with me" — surfaces every top-most folder/file the user
|
|
203
|
+
* has an explicit grant on (filtering out anything already covered by their
|
|
204
|
+
* scope defaults). Shaped like a folder so the existing folder-browser UI can
|
|
205
|
+
* render it without special cases.
|
|
206
|
+
*
|
|
207
|
+
* The pseudo-folder has a synthetic id (`shared-with-me`) and the foreign-key
|
|
208
|
+
* columns set to null — the UI should never try to mutate it directly. The
|
|
209
|
+
* union shape matches `browseFolder`'s return type so the same client query
|
|
210
|
+
* can render either.
|
|
211
|
+
*/
|
|
212
|
+
export async function browseSharedWithMe(userId: string) {
|
|
213
|
+
const { folders } = await listSharedWithMe(userId);
|
|
214
|
+
return {
|
|
215
|
+
folder: {
|
|
216
|
+
id: "shared-with-me",
|
|
217
|
+
name: "Shared with me",
|
|
218
|
+
color: null,
|
|
219
|
+
classId: null,
|
|
220
|
+
schoolStorageId: null,
|
|
221
|
+
personalOwnerId: null,
|
|
222
|
+
scope: "GENERIC" as const,
|
|
223
|
+
parentFolderId: null,
|
|
224
|
+
parentFolder: null,
|
|
225
|
+
childFolders: folders.map((f) => ({
|
|
226
|
+
id: f.id,
|
|
227
|
+
name: f.name,
|
|
228
|
+
color: f.color,
|
|
229
|
+
_count: { files: 0, childFolders: 0 },
|
|
230
|
+
})),
|
|
231
|
+
// No file-level grants in v1 — files always live under a granted folder.
|
|
232
|
+
files: [] as {
|
|
233
|
+
id: string;
|
|
234
|
+
name: string;
|
|
235
|
+
type: string;
|
|
236
|
+
size: number | null;
|
|
237
|
+
path: string;
|
|
238
|
+
uploadedAt: Date | null;
|
|
239
|
+
user: { id: string; username: string } | null;
|
|
240
|
+
}[],
|
|
241
|
+
},
|
|
242
|
+
breadcrumbs: [{ id: "shared-with-me", name: "Shared with me" }],
|
|
243
|
+
canWrite: false,
|
|
244
|
+
scope: { kind: "PERSONAL" as const, userId } satisfies CloudScope,
|
|
245
|
+
/** Same `displayRoot` shape as `browseFolder` so the client union is uniform. */
|
|
246
|
+
displayRoot: {
|
|
247
|
+
kind: "SHARED_WITH_ME" as const,
|
|
248
|
+
name: "Shared with me",
|
|
249
|
+
},
|
|
181
250
|
};
|
|
182
251
|
}
|
|
183
252
|
|
|
@@ -250,7 +319,11 @@ export async function updateFolderRecord(
|
|
|
250
319
|
message: "Cannot rename a root folder",
|
|
251
320
|
});
|
|
252
321
|
}
|
|
253
|
-
|
|
322
|
+
// Rename targets the folder itself — Drive-style guard reserves this for
|
|
323
|
+
// scope-default WRITE (explicit grants don't authorize renaming the share).
|
|
324
|
+
await requireFolderAccess(userId, input.folderId, "WRITE", {
|
|
325
|
+
mutatesSelf: true,
|
|
326
|
+
});
|
|
254
327
|
return renameFolder(
|
|
255
328
|
input.folderId,
|
|
256
329
|
input.name?.trim() ?? folder.name,
|
|
@@ -269,7 +342,8 @@ export async function deleteFolder(userId: string, folderId: string) {
|
|
|
269
342
|
message: "Cannot delete a root folder",
|
|
270
343
|
});
|
|
271
344
|
}
|
|
272
|
-
|
|
345
|
+
// Delete targets the folder itself — same Drive-style guard as rename.
|
|
346
|
+
await requireFolderAccess(userId, folderId, "WRITE", { mutatesSelf: true });
|
|
273
347
|
await deleteFolderById(folderId);
|
|
274
348
|
return { success: true };
|
|
275
349
|
}
|
|
@@ -297,7 +371,12 @@ export async function moveFolder(
|
|
|
297
371
|
message: "Cannot move folders between scopes",
|
|
298
372
|
});
|
|
299
373
|
}
|
|
300
|
-
|
|
374
|
+
// Moving the folder targets it (and the new parent's contents). Source-side
|
|
375
|
+
// is mutates-self; target-side is regular WRITE on the new parent.
|
|
376
|
+
await requireFolderAccess(userId, input.folderId, "WRITE", {
|
|
377
|
+
mutatesSelf: true,
|
|
378
|
+
});
|
|
379
|
+
await requireFolderAccess(userId, input.targetParentFolderId, "WRITE");
|
|
301
380
|
|
|
302
381
|
// Don't let a folder land under one of its own descendants.
|
|
303
382
|
let cursor: string | null = input.targetParentFolderId;
|
|
@@ -376,6 +455,11 @@ export async function listUserScopes(userId: string) {
|
|
|
376
455
|
orderBy: { name: "asc" },
|
|
377
456
|
});
|
|
378
457
|
|
|
458
|
+
// Light counts for the "Shared with me" bucket so the UI can hide the entry
|
|
459
|
+
// when there's nothing to show. Cheap to compute — just counts top-most
|
|
460
|
+
// grants, not folder/file metadata.
|
|
461
|
+
const sharedWithMe = await listSharedWithMe(userId);
|
|
462
|
+
|
|
379
463
|
return {
|
|
380
464
|
personal: { kind: "PERSONAL" as const, userId },
|
|
381
465
|
schools: memberships.map((m) => ({
|
|
@@ -385,5 +469,10 @@ export async function listUserScopes(userId: string) {
|
|
|
385
469
|
role: m.role,
|
|
386
470
|
})),
|
|
387
471
|
classes,
|
|
472
|
+
sharedWithMe: {
|
|
473
|
+
kind: "SHARED_WITH_ME" as const,
|
|
474
|
+
folderCount: sharedWithMe.folders.length,
|
|
475
|
+
fileCount: sharedWithMe.files.length,
|
|
476
|
+
},
|
|
388
477
|
};
|
|
389
478
|
}
|